Using Cloudflare Turnstile in PHP

Cloudflare Turnstile is a free CAPTCHA alternative that shipped in 2022. Most users never see it — no image puzzles, no checkboxes. It reads browser signals and prior Cloudflare interactions to decide if a visitor is human, then drops a signed token into your form for server-side verification.

Unlike reCAPTCHA, Turnstile doesn't funnel interaction data through Google's ad infrastructure. If you have GDPR obligations or just don't want a third party profiling your users, Turnstile is the obvious pick.

Prerequisites

Step 1: Add the Front-End Widget

Load the Turnstile script and drop the widget div inside your form, before the submit button. It renders automatically on page load and writes the challenge token into a hidden field called cf-turnstile-response.

<!-- In your <head> or before </body> -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form method="post" action="/contact">
    <input type="text" name="name" placeholder="Your name" />
    <input type="email" name="email" placeholder="Your email" />

    <!-- Turnstile widget — must be inside the form -->
    <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

    <button type="submit">Send</button>
</form>

On submit, the browser includes cf-turnstile-response in the POST body automatically. No extra JavaScript needed.

Step 2: Verify the Token in PHP

POST the token to Cloudflare's siteverify endpoint. Include the visitor's IP — Cloudflare uses it to improve accuracy. You get back JSON with a success boolean.

<?php
/**
 * Verify a Cloudflare Turnstile token.
 *
 * @param string $token     The cf-turnstile-response POST value
 * @param string $secretKey Your Turnstile secret key
 * @param string $remoteIp  Optional: visitor's IP address (recommended)
 * @return bool
 */
function verifyTurnstileToken(string $token, string $secretKey, string $remoteIp = ''): bool
{
    if (empty($token)) {
        return false;
    }

    $postData = ['secret' => $secretKey, 'response' => $token];
    if ($remoteIp !== '') {
        $postData['remoteip'] = $remoteIp;
    }

    $context = stream_context_create([
        'http' => [
            'method'  => 'POST',
            'header'  => 'Content-Type: application/x-www-form-urlencoded',
            'content' => http_build_query($postData),
            'timeout' => 5,
        ],
    ]);

    $response = @file_get_contents(
        'https://challenges.cloudflare.com/turnstile/v0/siteverify',
        false,
        $context
    );

    if ($response === false) {
        // Network error — decide whether to fail open or closed
        return false;
    }

    $data = json_decode($response, true);
    return $data['success'] ?? false;
}

// In your form handler:
$token = $_POST['cf-turnstile-response'] ?? '';

if (!verifyTurnstileToken($token, 'YOUR_SECRET_KEY', $_SERVER['REMOTE_ADDR'])) {
    http_response_code(400);
    exit('Bot check failed. Please try again.');
}

// Proceed with form processing

Using cURL Instead of file_get_contents

Plenty of shared hosts disable allow_url_fopen. The cURL extension is almost always available. Same logic, different transport.

<?php
function verifyTurnstileCurl(string $token, string $secretKey, string $remoteIp = ''): bool
{
    if (empty($token)) {
        return false;
    }

    $postData = ['secret' => $secretKey, 'response' => $token];
    if ($remoteIp !== '') {
        $postData['remoteip'] = $remoteIp;
    }

    $ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($postData),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 5,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
    ]);

    $response = curl_exec($ch);
    curl_close($ch);

    if (!$response) {
        return false;
    }

    $data = json_decode($response, true);
    return $data['success'] ?? false;
}

Full Form Example

A self-contained contact form — display and submission in one file. You'd split these apart in production, but this shows every moving piece together.

<?php
define('TURNSTILE_SITE_KEY',   'YOUR_SITE_KEY');
define('TURNSTILE_SECRET_KEY', 'YOUR_SECRET_KEY');

$error   = '';
$success = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['cf-turnstile-response'] ?? '';

    if (!verifyTurnstileCurl($token, TURNSTILE_SECRET_KEY, $_SERVER['REMOTE_ADDR'])) {
        $error = 'Bot check failed. Please try again.';
    } else {
        // Handle form data
        $name  = htmlspecialchars(trim($_POST['name']  ?? ''));
        $email = htmlspecialchars(trim($_POST['email'] ?? ''));
        // ... send email, save to DB, etc.
        $success = true;
    }
}
?>
<!DOCTYPE html>
<html>
<head>
    <title>Contact</title>
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<?php if ($success): ?>
    <p>Thank you — your message was sent.</p>
<?php else: ?>
    <?php if ($error): ?><p style="color:red"><?= $error ?></p><?php endif ?>
    <form method="post">
        <input type="text"  name="name"  placeholder="Name"  />
        <input type="email" name="email" placeholder="Email" />
        <div class="cf-turnstile" data-sitekey="<?= TURNSTILE_SITE_KEY ?>"></div>
        <button type="submit">Send</button>
    </form>
<?php endif ?>
</body>
</html>

AJAX Forms

Submitting via fetch or XMLHttpRequest? The widget already populates cf-turnstile-response in your form data. Just send it. Server-side verification is identical.

document.querySelector('form').addEventListener('submit', async function (e) {
    e.preventDefault();

    const formData = new FormData(this);
    // cf-turnstile-response is already present in formData — the widget populates it

    const res = await fetch('/contact', { method: 'POST', body: formData });

    if (!res.ok) {
        const text = await res.text();
        document.getElementById('error').textContent = text;
    } else {
        document.getElementById('success').hidden = false;
    }
});

If the user fails and retries, call turnstile.reset() to get a fresh token before resubmitting.

Troubleshooting

Further Reading