How to Implement Cloudflare Turnstile Captcha in PHP

14 Jan. 2026
I needed Cloudflare Turnstile in a PHP project, and I couldn't find an example that didn't assume a framework. Cloudflare's documentation is thorough, but the examples almost always showed the JavaScript integration or assumed a specific PHP framework. I needed something basic I could adapt to any PHP project, so I wrote this guide and published the code.
Turnstile is an alternative to traditional CAPTCHAs: it doesn't make users solve puzzles or spot traffic lights, it works in the background and checks that a visitor is human without friction. And you don't need to route your traffic through Cloudflare to use it. This site's contact form uses exactly this server-side check, inside its Laravel API.
Prerequisites
Before the code you need the keys. From the Cloudflare dashboard, under Turnstile, create a new site and register your domain. You get two keys: the Site Key, which goes in your frontend HTML, and the Secret Key, which stays on the server for verification.
The frontend
The HTML form with the Turnstile widget is surprisingly simple: include the script in the head, add the cf-turnstile div where you want the widget, and put your Site Key in place of the placeholder.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Turnstile Example</title>
<!-- Load Cloudflare Turnstile Script -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<h1>Contact Form</h1>
<form action="submit-example.php" method="POST">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="message">Message:</label>
<textarea id="message" name="message" required></textarea>
<!-- Cloudflare Turnstile Widget -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY_HERE"></div>
<button type="submit">Submit</button>
</form>
</body>
</html>
When the form is submitted, Turnstile adds a hidden cf-turnstile-response field containing a token.
Server-side validation
This is the part most guides skip, and it's the one that matters: without server-side verification, a malicious user can submit the form bypassing the CAPTCHA entirely. The script takes the token, sends it to Cloudflare's verification endpoint along with the Secret Key using cURL, and processes the form data only if verification succeeds.
<?php
// submit-example.php
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Get the Turnstile response token
$turnstileResponse = $_POST['cf-turnstile-response'] ?? '';
// Your Secret Key from Cloudflare
$secretKey = 'YOUR_SECRET_KEY_HERE';
// Cloudflare Turnstile verification endpoint
$verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
// Prepare the data for verification
$data = [
'secret' => $secretKey,
'response' => $turnstileResponse,
'remoteip' => $_SERVER['REMOTE_ADDR'] // Optional but recommended
];
// Initialize cURL
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $verifyUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true
]);
// Execute the request
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
// Parse the JSON response
$result = json_decode($response, true);
// Check if verification was successful
if ($httpCode === 200 && isset($result['success']) && $result['success'] === true) {
// CAPTCHA verification passed!
// Process your form data here
$name = htmlspecialchars($_POST['name']);
$email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);
$message = htmlspecialchars($_POST['message']);
// Your form processing logic
echo "Thank you, " . $name . "! Your message has been received.";
// Send email, save to database, etc.
} else {
// CAPTCHA verification failed
$errorCodes = $result['error-codes'] ?? ['unknown-error'];
echo "CAPTCHA verification failed. Error codes: " . implode(', ', $errorCodes);
// Log the error or redirect back to form
}
} else {
// Handle non-POST requests
header('Location: form-example.php');
exit;
}
?>
Error codes
If verification fails, Turnstile returns an error code. The most common ones: missing-input-secret and invalid-input-secret (missing or wrong Secret Key), missing-input-response and invalid-input-response (missing, wrong or expired token), and timeout-or-duplicate (token already validated or timed out).
A few security rules
- The Secret Key never leaves the server: keep it in an environment variable or a protected config file.
- Always validate server-side: client-side validation alone can be bypassed.
- Pass the client IP too: it helps Cloudflare spot suspicious patterns.
- Always use HTTPS.
Customizing the widget
The widget takes a few options through data- attributes: theme (light, dark, auto), size, language and appearance.
<div class="cf-turnstile"
data-sitekey="YOUR_SITE_KEY_HERE"
data-theme="light"
data-size="normal"
data-language="en">
</div>
For development, Cloudflare provides test keys that always pass, handy for trying the integration before using the real ones.
The complete code, with the form and submission script ready to use, is in the GitHub repository. For everything else, see the official Turnstile documentation.

