Use cloudflare turnstile with htmx

https://images.fmacedo.com/turnstile_htmx.png

What is cloudflare’s turnstile?

Cloudflare has a captcha replacement to check if users are real or not called turnstile. It can be useful for a number of scenarios, but a common one is to protect contact forms, so you don’t get bombarded with bots.

If you follow the docs the implementation is pretty straightforward. For example, to protect a contact form:

  • You insert the Turnstile script snippet in your HTML’s <head> element:
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  • you add a piece of html to include the turnstile:
    <div class="cf-turnstile" data-sitekey="yourSitekey"></div>
  • in your backend you call cloudflare API to validate it
  • if the validation passes, you can submit the form
  • If not, you can display an error saying the validation failed

The problem

When you try to implement this with a typical old-school website (MPA, or multi page application) it works great: when the form page loads, the turnstile widget is rendered automatically. However, when you come from an HTMX request, the widget is not rendered. This is because the turnstile api script is already on the page before your htmx request, and therefore is not aware that a new div.cf-turnstile element has joined the page.

The solution

A simple solution is to render the widget explicitly after an htmx request happens. For that we can use the htmx:afterRequet event:

function renderTurnstileWidget() {
  // Get all elements with the class 'cf-turnstile'
  const turnstileElements = document.querySelectorAll('.cf-turnstile');
  // Loop through each Turnstile element
  turnstileElements.forEach((element) => {
    // Get the data attributes
    const siteKey = element.dataset.sitekey;
    const theme = element.dataset.theme;

    // Render the Turnstile captcha
    window.turnstile.render(element, {
      siteKey: siteKey,
      theme: theme
    });
  });
};
document.addEventListener('htmx:afterRequest', function(evt) {
  renderTurnstileWidget();
});

This will ensure that any turnstile widget is rendered properly in both scenarios: after an htmx or standard http request. Here’s a contact page example:

<!DOCTYPE html>
<html lang="en">
    <head>
        <script src="https://challenges.cloudflare.com/turnstile/v0/api.js"
                async
                defer></script>
        <script type="text/javascript"  defer>
            function renderTurnstileWidget() {
                // Get all elements with the class 'cf-turnstile'
                const turnstileElements = document.querySelectorAll('.cf-turnstile');
                // Loop through each Turnstile element
                turnstileElements.forEach((element) => {
                    // Get the data attributes
                    const siteKey = element.dataset.sitekey;
                    const theme = element.dataset.theme;
                
                    // Render the Turnstile captcha
                    window.turnstile.render(element, {
                    siteKey: siteKey,
                    theme: theme
                    });
                });
                };
                document.addEventListener('htmx:afterRequest', function(evt) {
                renderTurnstileWidget();
                });
        </script>
    </head>
    <body hx-boost="true">
        <h1>
            Contact Page
        </h1>
        <form method="POST">
            <textarea name="message"
                      cols="40"
                      rows="5"
                      required=""></textarea>

            <div class="cf-turnstile"
                 data-theme="light"
                 required=""
                 id="id_turnstile"
                 data-sitekey="1x00000000000000000000AA">
            </div>
            <button type="submit">
                Submit
            </button>
        </form>
    </body>
</html>

⭐ BONUS - How to use it in Django?

If you, like me, are in the Django world, there’s a nice package that takes care of the form submission and validation called django-turnstile. However, it injects the turnstile api script directly in the widget, which doesn’t work with htmx.

My solution was to remove it from the widget html and just add it to the <header> of the page like in the above example. Just override turnstile/forms/widgets/turnstile_widget.html with this:

<!-- someapp/templates/turnstile/forms/widgets/turnstile_widget.html -->

<div class="cf-turnstile" {% include "django/forms/widgets/attrs.html" %}></div>

Now you are not loading the api script twice.

Conclusion

There’s probably other ways of solving this problem, so let me know if you find another!


Click here to share this article with your friends on X if you liked it.