Programster's Blog

Tutorials focusing on Linux, programming, and open-source

Implementing Cloudflare Turnstile CAPTCHA

Cloudflare Turnstiles is a CAPTCHA mechanism to prevent bots performing actions (usually the submission of forms). It is incredibly simple to set up, and I have created a working demo PHP codebase available on GitHub that is based on thistutorial.

Steps

Generate Widget Site Key and Secret

After logging into Cloudflare, go to Application security (1) and then Turnstile (2).



If this is your first time like me, just click Add widget.



Give a name for your widget (which I would suggest be a name for your form), and then click Add Hostnames (2) for us to add the FQDN of our website.



A panel will appear from the right. Enter your sites fully qualified domain name (FQDN) and then click Add (2).



Now select the hostname you just entered (1) and click Add (2).



Now the panel will disappear, and your site's hostname should appear. Select it (1) and then select the Widget Mode (2). I would recommend leaving it on Managed to have the most effective solution (lowest rate of false positives). Then choose whether you would like to opt for pre-clearance on the site. Finally click the Create button.



You will now be given your Site key and Secret. Copy these down and we will use them in the implementation steps further on.

Implementation

There are many ways to implement this, but believe it is probably best to use the following method in which we use JavaScript to complete the "challenge" and only allow the form's submit button to be enabled if the user has passed the challenge. This is more user friendly than allowing the user to submit the form, and have failed the challenge, possibly frustrating the user with needing to fill in all of the details again. At least if a legitimate user "fails" incorrectly, then the user has the ability to see this and try to copy the details and try again.

Frontend

Add the following to the <head> section of your site to include the necessary JS code.

<!-- resource hint to optimize loading performance. Not strictly required -->
<link rel="preconnect" href="https://challenges.cloudflare.com">

<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
  async
  defer
></script>

You could download the script to "cache" it and serve locally, but this will cause the turnstile to stop working if there are future updates. How likely are you to notice if it suddenly stops working?

Then add the following inside your form:

<div
    class="cf-turnstile"
    data-sitekey="<YOUR-SITE-KEY>"
    data-theme="auto"
    data-size="flexible"
    data-callback="onTurnstileSuccess"
    data-error-callback="onTurnstileError"
    data-expired-callback="onTurnstileExpired"
  ></div>

Be sure to put your site key in where it says <YOUR-SITE-KEY>.

Then make sure to set your submit button to disabled by default, so that the turnstile later enables it if the user passes.

<button type="submit" id="submit-btn" disabled>Send Message</button>

Then add this JavaScript below the form for it to enable the submit button if the user passes the challenge (you may likely wish to remove the console logging once you are happy):

<script>
  function onTurnstileSuccess(token) {
    console.log("Turnstile success:", token);
    document.getElementById("submit-btn").disabled = false;
  }
  function onTurnstileError(errorCode) {
    console.error("Turnstile error:", errorCode);
    document.getElementById("submit-btn").disabled = true;
  }
  function onTurnstileExpired() {
    console.warn("Turnstile token expired");
    document.getElementById("submit-btn").disabled = true;
  }
</script>

Backend Implementation

Now that the frontend is "done", we need to handle this in the backend. When a normal user submits the form, it will have a token from the Turnstile, that we need to verify is legitimate. If you do not see this token, then you immediately know that this was a hack or bot and can throw away the request. If the token is present, we have to verify it to make sure that it wasn't just made up, or failed.

<?php

define('CF_TURNSTILE_SECRET_KEY', 'xxxxxxxxxxx');

function isValidToken(string $token, string $secret, ?string $remoteip = null) : bool
{
    if ($token !== "")
    {
        $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

        $data = [
            'secret' => $secret,
            'response' => $token
        ];

        if ($remoteip !== null) 
        {
            $data['remoteip'] = $remoteip;
        }

        $options = [
            'http' => [
                'header' => "Content-type: application/x-www-form-urlencoded\r\n",
                'method' => 'POST',
                'content' => http_build_query($data)
            ]
        ];

        $context = stream_context_create($options);
        $response = file_get_contents($url, false, $context);

        if ($response === FALSE) 
        {
            throw new \Exception("Internal Error - Failed to verify Turnstile token.")
        }

        $validationResult = json_decode($response, true);
        $isValidToken = $validationResult['success'];
    }
    else
    {
        $isValidToken = false;
    }

    return $isValidToken;
}


$token = $_POST['cf-turnstile-response'] ?? '';
$remoteIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];

if (isValidToken($token, CF_TURNSTILE_SECRET_KEY, $remoteIp)) 
{
    // Valid token - process form as normal here
    // ...
} 
else 
{
    // Invalid token - show error
    error_log('Turnstile validation failed: ' . implode(', ', $validation['error-codes']));

    // replace this with a proper HTML/JSON response
    die("Verification failed. Please try again.");
}

References

Last updated: 24th January 2026
First published: 23rd January 2026

This blog is created by Stuart Page

I'm a freelance web developer and technology consultant based in Surrey, UK, with over 10 years experience in web development, DevOps, Linux Administration, and IT solutions.

Need support with your infrastructure or web services?

Get in touch