Using Cloudflare Turnstile in PHP

Cloudflare Turnstile is a privacy-preserving CAPTCHA alternative launched in 2022. Unlike traditional CAPTCHAs, it's invisible to the vast majority of users — there are no image puzzles to solve. Turnstile analyses browser signals and past Cloudflare interactions to determine whether a visitor is human, issuing a signed token the server can verify.

It's free to use with any Cloudflare account, and unlike reCAPTCHA, it doesn't route interaction data through Google's advertising infrastructure. For teams with GDPR obligations or a desire to avoid third-party data sharing, Turnstile is the most straightforward drop-in choice.

Prerequisites

Step 1: Add the Front-End Widget

Load the Turnstile script and place the widget div inside your form, immediately before the submit button. The widget auto-renders on page load and injects the challenge token as a hidden field named cf-turnstile-response when the user passes the check.

<!-- 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>

When the form is submitted, the browser automatically includes cf-turnstile-response in the POST body. No JavaScript form handling is required for the basic setup.

Step 2: Verify the Token in PHP

On the server side, send the token to Cloudflare's siteverify endpoint. Always include the visitor's IP address — Cloudflare uses it as an additional signal and it improves accuracy. The endpoint returns 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

Many shared hosting environments disable allow_url_fopen, which prevents file_get_contents from making outbound HTTP requests. The cURL extension is almost always available as an alternative. The logic is identical — only the transport changes.

<?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

Below is a self-contained contact form that handles both display and submission in a single file. In a production application you would separate these concerns, but this pattern illustrates the complete integration at a glance.

<?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

If you submit your form via fetch or XMLHttpRequest, read the token from the widget's hidden input and include it in the request body before sending. The server-side verification function is unchanged.

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 the challenge and tries again, call turnstile.reset() to generate a fresh token before resubmitting.

Troubleshooting

Further Reading