Validate the token
Learn how to securely validate Turnstile tokens on your server using the Siteverify API.
:::caution[Mandatory server-side validation]
You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms.
Server-side validation is required because:
- Tokens can be forged. An attacker can submit any string to your form endpoint without completing a challenge.
- Tokens expire. Each token is valid for 300 seconds (5 minutes) after generation.
- Tokens are single-use. Each token can only be validated once. A replayed token will be rejected with the
timeout-or-duplicateerror code. :::
Process
- Client generates token: Visitor completes Turnstile challenge on your webpage.
- Token sent to server: Form submission includes the Turnstile token.
- Server validates token: Your server calls Cloudflare's Siteverify API.
- Cloudflare responds: Returns
successorfailureand additional data. - Server takes action: Allow or reject the original request based on validation.
Siteverify API overview
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
Request format
The API accepts both application/x-www-form-urlencoded and application/json requests, but always returns JSON responses.
Required parameters
| Parameter | Required | Description |
|---|---|---|
secret |
Yes | Your widget's secret key from the Cloudflare dashboard |
response |
Yes | The token from the client-side widget |
remoteip |
No | The visitor's IP address |
idempotency_key |
No | A UUID you generate to safely retry validation requests |
Token characteristics
- Maximum length: 2048 characters
- Validity period: 300 seconds (5 minutes) from generation
- Single use: Each token can only be validated once
- Automatic expiry: Tokens automatically expire and cannot be reused
The validation token issued by Turnstile is valid for five minutes. If a user submits the form after this period, the token is considered expired. In this scenario, the server-side verification API will return a failure, and the error-codes field in the response will include timeout-or-duplicate.
To ensure a successful validation, the visitor must initiate the request and submit the token to your backend within the five-minute window. Otherwise, the Turnstile widget needs to be refreshed to generate a new token. This can be done using the turnstile.reset function.
Basic validation examples
JavaScript
JSON
const SECRET_KEY = "your-secret-key";
async function validateTurnstile(token, remoteip) {
try {
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: SECRET_KEY,
response: token,
remoteip: remoteip,
}),
},
);
const result = await response.json();
return result;
} catch (error) {
console.error("Turnstile validation error:", error);
return { success: false, "error-codes": ["internal-error"] };
}
}
Form Data
const SECRET_KEY = "your-secret-key";
async function validateTurnstile(token, remoteip) {
const formData = new FormData();
formData.append("secret", SECRET_KEY);
formData.append("response", token);
formData.append("remoteip", remoteip);
try {
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: formData,
},
);
const result = await response.json();
return result;
} catch (error) {
console.error("Turnstile validation error:", error);
return { success: false, "error-codes": ["internal-error"] };
}
}
// Usage in form handler
async function handleFormSubmission(request) {
const body = await request.formData();
const token = body.get("cf-turnstile-response");
const ip =
request.headers.get("CF-Connecting-IP") ||
request.headers.get("X-Forwarded-For") ||
"unknown";
const validation = await validateTurnstile(token, ip);
if (validation.success) {
// Token is valid - process the form
console.log("Valid submission from:", validation.hostname);
return processForm(body);
} else {
// Token is invalid - reject the submission
console.log("Invalid token:", validation["error-codes"]);
return new Response("Invalid verification", { status: 400 });
}
}
PHP
<?php
function validateTurnstile($token, $secret, $remoteip = null) {
$url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$data = [
'secret' => $secret,
'response' => $token
];
if ($remoteip) {
$data['remoteip'] = $remoteip;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($response === false || $error !== '') {
return ['success' => false, 'error-codes' => ['internal-error']];
}
return json_decode($response, true);
}
// Usage
$secret_key = 'your-secret-key';
$token = $_POST['cf-turnstile-response'] ?? '';
$remoteip = $_SERVER['HTTP_CF_CONNECTING_IP'] ??
$_SERVER['HTTP_X_FORWARDED_FOR'] ??
$_SERVER['REMOTE_ADDR'];
$validation = validateTurnstile($token, $secret_key, $remoteip);
if ($validation['success']) {
// Valid token - process form
echo "Form submission successful!";
// Process your form data here
} else {
// Invalid token - show error
echo "Verification failed. Please try again.";
error_log('Turnstile validation failed: ' . implode(', ', $validation['error-codes']));
}
?>
Python
import requests
def validate_turnstile(token, secret, remoteip=None):
url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
data = {
'secret': secret,
'response': token
}
if remoteip:
data['remoteip'] = remoteip
try:
response = requests.post(url, data=data, timeout=10)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
print(f"Turnstile validation error: {e}")
return {'success': False, 'error-codes': ['internal-error']}
# Usage with Flask
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = 'your-secret-key'
@app.route('/submit-form', methods=['POST'])
def submit_form():
token = request.form.get('cf-turnstile-response')
remoteip = request.headers.get('CF-Connecting-IP') or \
request.headers.get('X-Forwarded-For') or \
request.remote_addr
validation = validate_turnstile(token, SECRET_KEY, remoteip)
if validation['success']:
# Valid token - process form
return jsonify({'status': 'success', 'message': 'Form submitted successfully'})
else:
# Invalid token - reject submission
return jsonify({
'status': 'error',
'message': 'Verification failed',
'errors': validation['error-codes']
}), 400
Java
import org.springframework.web.client.RestTemplate;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@Service
public class TurnstileService {
private static final String SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
private final String secretKey = "your-secret-key";
private final RestTemplate restTemplate = new RestTemplate();
public TurnstileResponse validateToken(String token, String remoteip) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("secret", secretKey);
params.add("response", token);
if (remoteip != null) {
params.add("remoteip", remoteip);
}
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
try {
ResponseEntity<TurnstileResponse> response = restTemplate.postForEntity(
SITEVERIFY_URL, request, TurnstileResponse.class);
return response.getBody();
} catch (Exception e) {
TurnstileResponse errorResponse = new TurnstileResponse();
errorResponse.setSuccess(false);
errorResponse.setErrorCodes(List.of("internal-error"));
return errorResponse;
}
}
}
// Controller usage
@PostMapping("/submit-form")
public ResponseEntity<?> submitForm(
@RequestParam("cf-turnstile-response") String token,
HttpServletRequest request) {
String remoteip = request.getHeader("CF-Connecting-IP");
if (remoteip == null) {
remoteip = request.getHeader("X-Forwarded-For");
}
if (remoteip == null) {
remoteip = request.getRemoteAddr();
}
TurnstileResponse validation = turnstileService.validateToken(token, remoteip);
if (validation.isSuccess()) {
// Valid token - process form
return ResponseEntity.ok("Form submitted successfully");
} else {
// Invalid token - reject submission
return ResponseEntity.badRequest()
.body("Verification failed: " + validation.getErrorCodes());
}
}
C#
using System.Text.Json;
public class TurnstileService
{
private readonly HttpClient _httpClient;
private readonly string _secretKey = "your-secret-key";
private const string SiteverifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
public TurnstileService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<TurnstileResponse> ValidateTokenAsync(string token, string remoteip = null)
{
var parameters = new Dictionary<string, string>
{
{ "secret", _secretKey },
{ "response", token }
};
if (!string.IsNullOrEmpty(remoteip))
{
parameters.Add("remoteip", remoteip);
}
var postContent = new FormUrlEncodedContent(parameters);
try
{
var response = await _httpClient.PostAsync(SiteverifyUrl, postContent);
var stringContent = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<TurnstileResponse>(stringContent);
}
catch (Exception ex)
{
return new TurnstileResponse
{
Success = false,
ErrorCodes = new[] { "internal-error" }
};
}
}
}
// Controller usage
[HttpPost("submit-form")]
public async Task<IActionResult> SubmitForm([FromForm] string cfTurnstileResponse)
{
var remoteip = HttpContext.Request.Headers["CF-Connecting-IP"].FirstOrDefault() ??
HttpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault() ??
HttpContext.Connection.RemoteIpAddress?.ToString();
var validation = await _turnstileService.ValidateTokenAsync(cfTurnstileResponse, remoteip);
if (validation.Success)
{
// Valid token - process form
return Ok("Form submitted successfully");
}
else
{
// Invalid token - reject submission
return BadRequest($"Verification failed: {string.Join(", ", validation.ErrorCodes)}");
}
}
Advanced validation techniques
const crypto = require("crypto");
async function validateWithRetry(token, remoteip, maxRetries = 3) {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const formData = new FormData();
formData.append("secret", SECRET_KEY);
formData.append("response", token);
formData.append("remoteip", remoteip);
formData.append("idempotency_key", idempotencyKey);
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (response.ok) {
return result;
}
// If this is the last attempt, return the error
if (attempt === maxRetries) {
return result;
}
// Wait before retrying (exponential backoff)
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000),
);
} catch (error) {
if (attempt === maxRetries) {
return { success: false, "error-codes": ["internal-error"] };
}
}
}
}
async function validateTurnstileEnhanced(
token,
remoteip,
expectedAction = null,
expectedHostname = null,
) {
const validation = await validateTurnstile(token, remoteip);
if (!validation.success) {
return {
valid: false,
reason: "turnstile_failed",
errors: validation["error-codes"],
};
}
// Check if action matches expected value (if specified)
if (expectedAction && validation.action !== expectedAction) {
return {
valid: false,
reason: "action_mismatch",
expected: expectedAction,
received: validation.action,
};
}
// Check if hostname matches expected value (if specified)
if (expectedHostname && validation.hostname !== expectedHostname) {
return {
valid: false,
reason: "hostname_mismatch",
expected: expectedHostname,
received: validation.hostname,
};
}
// Check token age (warn if older than 4 minutes)
const challengeTime = new Date(validation.challenge_ts);
const now = new Date();
const ageMinutes = (now - challengeTime) / (1000 * 60);
if (ageMinutes > 4) {
console.warn(`Token is ${ageMinutes.toFixed(1)} minutes old`);
}
return {
valid: true,
data: validation,
tokenAge: ageMinutes,
};
}
// Usage
const result = await validateTurnstileEnhanced(
token,
remoteip,
"login", // expected action
"example.com", // expected hostname
);
if (result.valid) {
// Process the request
console.log("Validation successful:", result.data);
} else {
// Handle validation failure
console.log("Validation failed:", result.reason);
}
API response format
Successful response
{
"success": true,
"challenge_ts": "2022-02-28T15:14:30.096Z",
"hostname": "example.com",
"error-codes": [],
"action": "login",
"cdata": "sessionid-123456789",
"metadata": {
"ephemeral_id": "x:9f78e0ed210960d7693b167e"
}
}
Failed response
{
"success": false,
"error-codes": ["invalid-input-response"]
}
Response fields
| Field | Description |
|---|---|
success |
Boolean indicating if validation was successful |
challenge_ts |
ISO 8601 timestamp when the challenge was solved |
hostname |
Hostname where the challenge was served |
error-codes |
Array of error codes (if validation failed) |
action |
Custom action identifier from client-side |
cdata |
Custom data payload from client-side |
metadata.ephemeral_id |
Device fingerprint ID (Enterprise only) |
Error codes reference
| Error code | Description | Action required |
|---|---|---|
missing-input-secret |
Secret parameter not provided | Ensure secret key is included |
invalid-input-secret |
Secret key is invalid or expired | Check your secret key in the Cloudflare dashboard |
missing-input-response |
Response parameter was not provided | Ensure token is included |
invalid-input-response |
Token is invalid, malformed, or expired | User should retry the challenge |
bad-request |
Request is malformed | Check request format and parameters |
timeout-or-duplicate |
Token has already been validated | Each token can only be used once |
internal-error |
Internal error occurred | Retry the request |
Implementation
class TurnstileValidator {
constructor(secretKey, timeout = 10000) {
this.secretKey = secretKey;
this.timeout = timeout;
}
async validate(token, remoteip, options = {}) {
// Input validation
if (!token || typeof token !== "string") {
return { success: false, error: "Invalid token format" };
}
if (token.length > 2048) {
return { success: false, error: "Token too long" };
}
// Prepare request
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const formData = new FormData();
formData.append("secret", this.secretKey);
formData.append("response", token);
if (remoteip) {
formData.append("remoteip", remoteip);
}
if (options.idempotencyKey) {
formData.append("idempotency_key", options.idempotencyKey);
}
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: formData,
signal: controller.signal,
},
);
const result = await response.json();
// Additional validation
if (result.success) {
if (
options.expectedAction &&
result.action !== options.expectedAction
) {
return {
success: false,
error: "Action mismatch",
expected: options.expectedAction,
received: result.action,
};
}
if (
options.expectedHostname &&
result.hostname !== options.expectedHostname
) {
return {
success: false,
error: "Hostname mismatch",
expected: options.expectedHostname,
received: result.hostname,
};
}
}
return result;
} catch (error) {
if (error.name === "AbortError") {
return { success: false, error: "Validation timeout" };
}
console.error("Turnstile validation error:", error);
return { success: false, error: "Internal error" };
} finally {
clearTimeout(timeoutId);
}
}
}
// Usage
const validator = new TurnstileValidator(process.env.TURNSTILE_SECRET_KEY);
const result = await validator.validate(token, remoteip, {
expectedAction: "login",
expectedHostname: "example.com",
});
if (result.success) {
// Process the request
} else {
// Handle failure
console.log("Validation failed:", result.error);
}
Testing
Best practices
Security
- Store your secret keys securely. Use environment variables or secure key management.
- Validate the token on every request. Never trust client-side validation alone.
- Check additional fields. Validate the action and hostname when specified.
- Monitor for abuse and log failed validations and unusual patterns.
- Use HTTPS. Always validate over secure connections.
- Only call the Siteverify API in your backend environment. If you expose the secret key in the front-end client code to call Siteverify, attackers can bypass the security check. Ensure that your client-side code sends the validation token to your backend, and that your backend is the sole caller of the Siteverify API.
Performance
- Set reasonable timeouts. Do not wait indefinitely for Siteverify responses.
- Implement retry logic and handle temporary network issues.
- Cache validation results for the same token, if it is needed for your flow.
- Monitor your API latency. Track the Siteverify response time.
Error handling
- Have fallback behavior for API failures.
- Use user-friendly messaging. Do not expose internal error details to users.
- Properly log errors for debugging without exposing secrets.
- Rate limit to protect against validation flooding.
谢谢你的反馈!