FRD-001
Verify a Cloudflare Turnstile token server-side
The widget on the page proves nothing on its own. This is the server check, written to fail closed on every error path.
Turnstile puts a token in your form. Until your server asks Cloudflare whether that token is real, it is just a string a bot can post.
The important design decision is what happens when the check itself fails: no token, malformed response, network timeout. Fail open and a bot that blocks the Cloudflare request sails straight through, which is trivially easy to do. Fail closed and a Cloudflare outage locks out your signup form. Closed is the right default for a site handling money, but know which one you have chosen rather than discovering it later.
function turnstile_verify(string $secret, string $token, ?string $remoteIp = null): bool
{
if ($secret === '' || $token === '') {
return false; // fail closed
}
$post = ['secret' => $secret, 'response' => $token];
if ($remoteIp !== null && $remoteIp !== '') {
$post['remoteip'] = $remoteIp;
}
$ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($post),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
curl_close($ch);
if ($body === false) {
return false; // network failure = not verified
}
$data = json_decode((string) $body, true);
return is_array($data) && ($data['success'] ?? false) === true;
}
Using it
Run this after your CSRF check and after your rate limiter, not before. A bot hammering the form should be stopped by the cheap local check before you spend an outbound HTTP request on it.
The widget itself is one div and one script tag; keep both behind an admin toggle so the site works unchanged until keys are filled in.
What bites people
Tokens are single use and short lived. A user who sits on a form for ten minutes will fail; re-render the widget rather than showing a generic error.
Passing remoteip helps only if it is the real client address. Behind a proxy, an unvalidated X-Forwarded-For makes it worse than useless.