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
- A free Cloudflare account
- A site key and secret key — create them at Cloudflare Dashboard → Turnstile → Add Site
- PHP 7.2 or higher with either
allow_url_fopenenabled or the cURL extension available
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
- Token missing from POST: The Turnstile JavaScript failed to load. Check for CSP errors in the browser console and ensure the script tag is present and the domain matches your registered site key.
- "Invalid token" from the API: Tokens are single-use. If the user submits the form a second time (e.g., after a validation error), the token has already been consumed. Call
turnstile.reset()after each failed submission to obtain a new token. - Network error in PHP (
file_get_contentsreturns false):allow_url_fopenis likely disabled. Switch to the cURL version above. - Timeout errors: The default timeout is 5 seconds. If Cloudflare's API is unreachable, decide in advance whether your application should fail open (allow the submission) or fail closed (block it) and code accordingly.