Tutorial - How to integrate Cloudflare Turnstile – (Google reCAPTCHA alternative)

Here’s how I managed to integrate Cloudflare Turnstile client-side rendering with Weweb, and server-side validation with Xano.

This took some perseverance to get working so I thought others might find this useful to avoid similar pains. Here’s my first tutorial!

I think Cloudflare has become an extremely reputable company and they offer a nice smart CAPTCHA alternative, with the ability to add a widget that is either;

  • A non-interactive challenge.
  • A non-intrusive interactive challenge (such as clicking a button), if the visitor is a suspected bot.
  • An invisible challenge to the browser.

Important note – “the availability of Turnstile is currently in open beta and available as a free tool for all customers. For the beta, customers are limited to 1 million calls to the siteverify verification endpoint per month per site.”

Here is an example of what the widget looks like.

What is the purpose of this?

  1. Reduce malicious software from creating accounts or logging into applications by running a managed challenge for all site visitors on signup, login, or pages containing forms.
  2. If the challenge succeeds, pass the token for server-side validation.
  3. If the server-side validation passes, toggle the login/signup/submit button to ‘enabled’ so the site visitor can proceed.
  4. Else, prevent the site-visitor from proceeding and potentially engaging in unwanted activity or actions.

Cloudflare Configuration

  1. Before you get started, review the Cloudflare Documentation here;
    Get started · Cloudflare Turnstile docs

  2. You’ll need to add your domain or subdomain to your Cloudflare account, within the Turnstile product.
    image

Weweb Configuration

  1. Add a HTML element where you want to render the Cloudflare widget.
    image

  2. Add an ‘id’ to the HTML attributes.
    You’ll need this id to target the element to render the widget, and to get the response token. (I’ve called this ‘cf-turnstile-container’).
    NB: This element doesn’t have any other settings such as height etc or any code within the HTML element itself. I experienced some issues where the widget would render outside of the element and therefore resolved to having it appear empty in the editor view. Once published, the widget will render based on the size parameter within the JavaScript.
    image

  3. Create a ‘text’ variable to hold the Turnstile widget response.
    Copy the ID for this variable as we will update the variable value when the challenge succeeds. (I’ve called this ‘cf-turnstile-widget-response’).
    image

  4. Create an ‘object’ variable to store the response from the server-side validation. (I’ve called this ‘cf-site-verify-response’).
    image

  5. Create a Global Workflow to execute the server-side validation.
    Copy the workflow UID as we will execute the global workflow from the JavaScript code.
    In this workflow, I’ve set a simple condition to prevent the Xano endpoint being called without the variable containing a response.

We pass the ‘text’ variable we created to Xano.

We update the ‘object’ variable with the result.data

  1. Back on the page/s where you are rendering the widget, include a ‘text’ element that contains the global workflow UID.
    The display for this element can be set to :none.
    I’m sure there are other methods to include this, but the UID needs to be somewhere on the page. I resolved to doing it this way.
    (see related posts here regarding why you need to include the global workflow UID on the page)
    I name this element something obvious eg ‘globalWorkflow-UID-of-xanoCallback’
    image

  2. Button Configuration
    On your button create a state eg ‘btnDisabled’ so that we get visual feedback for whether the button is enabled.
    Here we set the state Condition.

This is the snippet used above

// Check the value of the global variable['cf-site-verify-response'] and return true if it's false or undefined, else return false
const variableValue = variables['e482604c-.......-19bbe3848545']?.['success'];
const result = variableValue === false || variableValue === undefined ? true : false;

// Return the result
return result;

We’re changing the Text within the button for an added cue that something is occurring eg. ‘Verifying’. Otherwise simply set the button colour for enabled/disabled.

This is the snippet used above

// Check the value of the global variable['cf-site-verify-response'] and return the text 'Verifying' or 'Sign in' once a successful response has been received
const variableValue = variables['e482604c-........-19bbe3848545']?.['success'];
// Change the button text
const btnText= variableValue === false || variableValue === undefined ? 'Verifying': 'Sign in';

// Return the result
return btnText;

Within the additional settings, we’re disabling the button if the server-side validation response is false or undefined. Else, the button is enabled. (This is the same code as the code we inserted in the btnDisabled Condition)

  1. Add the Turnstile script and associated code to the Project Settings – Custom Code, Head.

Here’s some things to observe with the code;

  1. We’re explicitly rendering the Turnstile widget
  2. Set parameters ‘theme’, and ‘size’ as per your preferences.
  3. Set parameter ‘sitekey’ using the key located within your Cloudflare Account.
  4. Replace all occurrences of #cf-turnstile-container to the id you gave your html element.
  5. Update the text variable value – we use wwLib.wwVariable.updateValue(“INSERT YOUR VARIABLE ID”, response)
  6. Execute the global workflow - wwLib.executeWorkflow(“INSERT YOUR GLOBAL WORKFLOW UID”)
  7. MutationObserver – we need to use a MutationObserver function to check if the target html element is present and then initialize the Turnstile.

This is the full code I’ve added to the Custom Code

<!-- Cloudflare Turnstile explicitly render widget  -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" async defer>
</script>

<script>
function onloadTurnstileCallback() {
  turnstile.render(
    turnstile.execute("#cf-turnstile-container", {
      theme: "dark",
      sitekey: "0x4AA................vC_Xf",
      size: "normal",
      appearance: "always",
      callback: function (token) {
        console.log(`Challenge Success ${token}`);

        const retryLimit = 8;
        let retryCount = 0;

        const handleResponseAndRetry = function () {
          const response = turnstile.getResponse("#cf-turnstile-container");
          console.log(response);

          if (response == "") {
            if (retryCount < retryLimit) {
              retryCount++;
              console.log(
                `Empty response. Retrying (${retryCount}/${retryLimit})...`
              );
              setTimeout(handleResponseAndRetry, 1000); // Retry after 1 second
            } else {
              console.log("Maximum retry attempts reached. Exiting...");
              wwLib.executeWorkflow("6b1605eb-................-0d5ad47cf919");
            }
          } else {
            wwLib.wwVariable.updateValue(
              "8a799d83-................-6622265f38d4",
              response
            );
            wwLib.executeWorkflow("6b1605eb-................-0d5ad47cf919");
          }
        };
        handleResponseAndRetry();
      },
    })
  );
}

// Function to check if the target element is present and initialize the Turnstile
function checkAndInitialize() {
  const targetElement = document.querySelector("#cf-turnstile-container");
  if (targetElement) {
    onloadTurnstileCallback();
  } else {
    // If the target element is not found, observe the DOM changes of the document
    const observer = new MutationObserver(function (mutationsList, observer) {
      for (const mutation of mutationsList) {
        if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
          // Check if the target element is now present in the added nodes
          const targetElement = document.querySelector(
            "#cf-turnstile-container"
          );
          if (targetElement) {
            // Disconnect the observer once the target element is found
            observer.disconnect();
            // Load the external script when the target element is present
            onloadTurnstileCallback();
            break;
          }
        }
      }
    });

    observer.observe(document, { childList: true, subtree: true });
  }
}

// Call the function to check if the target element is present and initialize Turnstile
checkAndInitialize();
</script>

Xano Implementation

  1. Access your Secret Key, within your Cloudflare Turnstile account, and store it as an Environment Variable within Xano.
    image

  2. Create a single POST endpoint with a single text input

Configure the External API request
URL https://challenges.cloudflare.com/turnstile/v0/siteverify

Create a variable, and return the response result

If the server-side validation succeeds, we’ll get a key/value pair success: true.
This is the property we are targeting on our frontend to enable the login/signup/submit button.
image

Publish your application and test
Open DevTools Console
If everything is configured correctly, we expect to see

  1. the widget render, if the challenge succeeds our button will change states to enabled.
  2. in the console we’ll see a Challenge Success and token.
  3. In Xano we’ll get a 200 response with success: true
    image

Hope this is of help to others.

Credit and appreciation to @raydeck and @dorilama for guidance and forum contributions that helped me navigate this implementation!

8 Likes

That’s one comprehensive tutorial write up! Nice work @MichaelLovell!

This is insane! Thanks so much for putting this together @MichaelLovell :hugs:

Not using this one just yet - but you are a big plus to the community @MichaelLovell!!

1 Like

It’s devs like you @MichaelLovell that make me realise how far I am from mastering WeWeb. Thank you so much! I had spent a good 5hrs trying to figure it out on my own. Tried everything from ChatGPT to reading the docs. Lo and behold it was right under my nose in this community! I have to reorder my troubleshooting references.

** EDIT **
Man, I think I got excited too soon. I have tried following everything you have documented. But I still seem to get some weird behaviour. The widget appears for a split second and then goes missing.

On the console, it shows the following errors,
"Uncaught TurnstileError: [Cloudflare Turnstile] Invalid type for parameter “container”, expected “string” or an implementation of “HTMLElement”. "
“Uncaught TurnstileError: [Cloudflare Turnstile] Cannot initialize Widget, Element not found (#cf-chl-widget-yde6b)”

The ID seems to be correct. The scripts are loaded onto the Project Settings → Custom code section. Not sure what I am missing out on.

Can you give me a clear screenshot of your console log? Are you not getting a ‘Challenge success’ token once the container issue resolves?

It’s a timing issue that is throwing those errors. I still get one error log on the initial load, but then they resolve once the container has been found. See below:

Also, what are your settings in Cloudflare? This may be part of the reason for the behaviour to show and then disappear.

Hey man, took me a long while to get to the bottom of that behaviour. I only understood it after trying to implement hCaptcha instead, and ran into a similar issue.

It has something to do with the “Hydrate” option of that section where my form resides. Unfortunately, my form uses the date/time picker components and that one requires the Hydrate to be switched on. But doing so, causes the captcha widgets to appear and disappear when viewing on the ‘Live’ site.

So, I really am just waiting on the support team to assist me further on the ticket I have just opened. Will update here if they do provide a fix for it, or unfortunately I will (very reluctantly) have to rollback to Google’s reCaptcha (which is so bloated in size and performance).

Thanks once again. Will reach out if I run into any more problems.

@MOOLAH I read your other thread about setting up hCaptcha and came across your response here. Are you saying that using the Hydrate section for the response is the solution to setting up hCaptcha properly?

Hey, for my use case, it seems like each time I leave the Hydrate turned on for the section containing my form, the captcha appears and disappears. When i turn it off, it stays and behaves as expected. But then, it breaks my other form’s inputs. :frowning:

Hey @MichaelLovell sorry as I was away building the other parts of my project and finally returned to tackle this. I seem to have come quite far in following your implementation. I am going to try and be meticulous with all that I share here.

This is my form I would like a Turnnstile implementation for,

In my Dev console, I see these few errors, but I think like you mentioned before, most of them are some timing related thing, so not really a bother,

This is what I have for my Condition on the button behaviour,

I had turned to storing a bool variable called verifiedToken and change it during my executeWorkflow of the global workflow for turnstile as seen here,

For the turnstile to appear, I had to switch of the Hydrate property of that section. When I explicitly turn it off, the Turnstile loads up, does its thing and shows the green checkmark. But for some weird reason, my update of the bool verifiedToken is not changing my button to its active state. I have followed your examples to the T but realised there could be some differences considering how you did not mention having to turn Hydrate off for that section for your Turnstile to work.

Can any experts help me out here? I am literally at 98% of my project and this thing has been holding me back a couple days. I would truly appreciate it. thank you in advance.

Here is my custom code implementation,


Thanks once again.

Mmm the bool variable isn’t updating.

I see you are updating 2 different variables in your code?

Since my original post I changed the handleResponseAndRetry() slightly to use a while loop.

function onloadTurnstileCallback() {
  turnstile.render(
    turnstile.execute("#cf-turnstile-container", {
      theme: "auto",
      sitekey: "0x4AAAAAAAEYm6zrbnnvC_Xf",
      size: "normal",
      appearance: "always",
      callback: function (token) {
        console.log(`Challenge Success ${token}`);

        const retryLimit = 8;
        let retryCount = 0;
        let response = "";

        function handleResponseAndRetry() {
        console.log("Starting handleResponseAndRetry")
          let response = turnstile.getResponse("#cf-turnstile-container");
          console.log(response);

          while (response === "" && retryCount < retryLimit) {
            retryCount++;
            console.log(`Empty response. Retrying (${retryCount}/${retryLimit})...`);
            setTimeout(handleResponseAndRetry, 1000); // Retry after 1 second
          }

          if (response === "" && retryCount === retryLimit) {
            console.log("Maximum retry attempts reached. Exiting...");
            wwLib.executeWorkflow("6b1605eb-6fb1-47dd-9ef4-0d5ad47cf919");
            return;
          }
          
          console.log(`Execute workflow 'cf-xanoCallback'. Retry count (${retryCount}/${retryLimit})...`);
          wwLib.wwVariable.updateValue(/*cf-turnstile-widget-response*/ "8a799d83-2049-4b21-a38c-6622265f38d4", response);
          wwLib.executeWorkflow(/*cf-xanoCallback workflow to  call backend site verify and update var cf-site-verify-response*/" 6b1605eb-6fb1-47dd-9ef4-0d5ad47cf919");
        }

        handleResponseAndRetry();
      },
    })
  )
}

Then my btnDisabled logic has remained unchanged.

// Check the value of the global variable['cf-site-verify-response'] and return true if it's false or undefined, else return false
const variableValue = variables['e482604c-3a53-454f-a4a5-19bbe3848545']?.['success'];
const result = variableValue === false || variableValue === undefined ? true : false;

// Return the result
return result;