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
- A free Cloudflare account
- A site key and secret key — create them at Cloudflare Dashboard → Turnstile → Add Site
- PHP 7.2+ with either
allow_url_fopenenabled or the cURL extension
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
- Token missing from POST: The Turnstile script didn't load. Check for CSP errors in the browser console and confirm the script tag is present and the domain matches your site key.
- "Invalid token" from the API: Tokens are single-use. If the user submits twice (say, after a validation error), the first token is already spent. Call
turnstile.reset()after each failed submission. - Network error in PHP (
file_get_contentsreturns false):allow_url_fopenis probably disabled. Switch to the cURL version above. - Timeout errors: The default timeout is 5 seconds. If Cloudflare's API is unreachable, decide up front whether your app fails open (allow the submission) or closed (block it), and code accordingly.