了解如何使用 Siteverify API 在您的服务器上安全地验证 Turnstile 令牌。
- 客户端生成令牌:访问者在您的网页上完成 Turnstile 质询。
- 令牌发送到服务器:提交表单时包含 Turnstile 令牌。
- 服务器验证令牌:您的服务器调用 Cloudflare 的 Siteverify API。
- Cloudflare 响应:返回
success(成功) 或failure(失败) 以及其他数据。 - 服务器执行操作:根据验证结果允许或拒绝原始请求。
POST https://challenges.cloudflare.com/turnstile/v0/siteverify该 API 既接受 application/x-www-form-urlencoded 请求,也接受 application/json 请求,但始终返回 JSON 格式的响应。
| 参数 | 是否必填 | 描述 |
|---|---|---|
secret |
是 | 来自 Cloudflare 仪表板的小组件密匙 |
response |
是 | 来自客户端小组件的令牌 |
remoteip |
否 | 访问者的 IP 地址 |
idempotency_key |
否 | 您生成的 UUID,用于安全地重试验证请求 |
- 最大长度:2048 字符
- 有效期:自生成起 300 秒(5 分钟)
- 一次性使用:每个令牌只能被验证一次
- 自动失效:令牌会自动失效且无法重复使用
由 Turnstile 签发的验证令牌有效期为五分钟。如果用户在此期限之后提交表单,该令牌将被视为已过期。在这种情况下,服务器端验证 API 将返回失败,响应中的 error-codes 字段将包含 timeout-or-duplicate。
为了确保验证成功,访问者必须在五分钟的窗口期内发起请求并将令牌提交到您的后端。否则,需要刷新 Turnstile 小组件以生成新令牌。这可以通过使用 turnstile.reset 函数来完成。
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 验证错误:", error);
return { success: false, "error-codes": ["internal-error"] };
}
}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 验证错误:", error);
return { success: false, "error-codes": ["internal-error"] };
}
}
// 在表单处理程序中使用
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) {
// 令牌有效 - 处理表单
console.log("有效提交来自:", validation.hostname);
return processForm(body);
} else {
// 令牌无效 - 拒绝提交
console.log("无效令牌:", validation["error-codes"]);
return new Response("验证无效", { status: 400 });
}
}<?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;
}
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data)
]
];
$context = stream_context_create($options);
$response = file_get_contents($url, false, $context);
if ($response === FALSE) {
return ['success' => false, 'error-codes' => ['internal-error']];
}
return json_decode($response, true);
}
// 使用方法
$secret_key = 'your-secret-key';
$token = $_POST['cf-turnstile-response'] ?? '';
$remoteip = $_SERVER['HTTP_CONNECTING_IP'] ??
$_SERVER['HTTP_X_FORWARDED_FOR'] ??
$_SERVER['REMOTE_ADDR'];
$validation = validateTurnstile($token, $secret_key, $remoteip);
if ($validation['success']) {
// 令牌有效 - 处理表单
echo "表单提交成功!";
// 在此处处理您的表单数据
} else {
// 令牌无效 - 显示错误
echo "验证失败。请重试。";
error_log('Turnstile 验证失败:' . implode(', ', $validation['error-codes']));
}
?>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 验证错误:{e}")
return {'success': False, 'error-codes': ['internal-error']}
# 在 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']:
# 令牌有效 - 处理表单
return jsonify({'status': 'success', 'message': '表单提交成功'})
else:
# 令牌无效 - 拒绝提交
return jsonify({
'status': 'error',
'message': '验证失败',
'errors': validation['error-codes']
}), 400import 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 使用
@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()) {
// 令牌有效 - 处理表单
return ResponseEntity.ok("表单提交成功");
} else {
// 令牌无效 - 拒绝提交
return ResponseEntity.badRequest()
.body("验证失败:" + validation.getErrorCodes());
}
}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 使用
[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)
{
// 令牌有效 - 处理表单
return Ok("表单提交成功");
}
else
{
// 令牌无效 - 拒绝提交
return BadRequest($"验证失败:{string.Join(", ", validation.ErrorCodes)}");
}
}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 (attempt === maxRetries) {
return result;
}
// 重试前等待(指数退避)
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"],
};
}
// 检查操作 (action) 是否与预期值匹配(如果已指定)
if (expectedAction && validation.action !== expectedAction) {
return {
valid: false,
reason: "action_mismatch",
expected: expectedAction,
received: validation.action,
};
}
// 检查域名是否与预期值匹配(如果已指定)
if (expectedHostname && validation.hostname !== expectedHostname) {
return {
valid: false,
reason: "hostname_mismatch",
expected: expectedHostname,
received: validation.hostname,
};
}
// 检查令牌生成时间(如果超过 4 分钟则警告)
const challengeTime = new Date(validation.challenge_ts);
const now = new Date();
const ageMinutes = (now - challengeTime) / (1000 * 60);
if (ageMinutes > 4) {
console.warn(`令牌已生成 ${ageMinutes.toFixed(1)} 分钟`);
}
return {
valid: true,
data: validation,
tokenAge: ageMinutes,
};
}
// 使用方法
const result = await validateTurnstileEnhanced(
token,
remoteip,
"login", // 预期的操作
"example.com", // 预期的域名
);
if (result.valid) {
// 处理请求
console.log("验证成功:", result.data);
} else {
// 处理验证失败
console.log("验证失败:", result.reason);
}{
"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"
}
}{
"success": false,
"error-codes": ["invalid-input-response"]
}| 字段 | 描述 |
|---|---|
success |
布尔值,指示验证是否成功 |
challenge_ts |
解决质询时的 ISO 8601 时间戳 |
hostname |
运行质询的域名 |
error-codes |
错误代码数组(如果验证失败) |
action |
来自客户端的自定义操作标识符 |
cdata |
来自客户端的自定义数据负载 |
metadata.ephemeral_id |
设备指纹 ID(仅限企业版) |
| 错误代码 | 描述 | 需要采取的操作 |
|---|---|---|
missing-input-secret |
未提供密匙 (secret) 参数 | 确保已包含密匙 |
invalid-input-secret |
密匙无效或已过期 | 在 Cloudflare 仪表板中检查您的密匙 |
missing-input-response |
未提供响应令牌 (response) 参数 | 确保已包含令牌 |
invalid-input-response |
令牌无效、格式错误或已过期 | 用户应重试质询 |
bad-request |
请求格式错误 | 检查请求格式和参数 |
timeout-or-duplicate |
令牌已被验证过 | 每个令牌只能使用一次 |
internal-error |
发生内部错误 | 重试该请求 |
class TurnstileValidator {
constructor(secretKey, timeout = 10000) {
this.secretKey = secretKey;
this.timeout = timeout;
}
async validate(token, remoteip, options = {}) {
// 输入验证
if (!token || typeof token !== "string") {
return { success: false, error: "令牌格式无效" };
}
if (token.length > 2048) {
return { success: false, error: "令牌过长" };
}
// 准备请求
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();
// 额外验证
if (result.success) {
if (
options.expectedAction &&
result.action !== options.expectedAction
) {
return {
success: false,
error: "操作不匹配",
expected: options.expectedAction,
received: result.action,
};
}
if (
options.expectedHostname &&
result.hostname !== options.expectedHostname
) {
return {
success: false,
error: "主机名不匹配",
expected: options.expectedHostname,
received: result.hostname,
};
}
}
return result;
} catch (error) {
if (error.name === "AbortError") {
return { success: false, error: "验证超时" };
}
console.error("Turnstile 验证错误:", error);
return { success: false, error: "内部错误" };
} finally {
clearTimeout(timeoutId);
}
}
}
// 使用方法
const validator = new TurnstileValidator(process.env.TURNSTILE_SECRET_KEY);
const result = await validator.validate(token, remoteip, {
expectedAction: "login",
expectedHostname: "example.com",
});
if (result.success) {
// 处理请求
} else {
// 处理失败
console.log("验证失败:", result.error);
}您可以使用测试密钥通过 Siteverify API 来测试使用测试站点密钥(sitekey)生成的虚拟令牌。您的生产环境密钥将拒绝虚拟令牌。
有关更多信息,请参阅测试。
- 安全地存储您的密匙。使用环境变量或安全密钥管理。
- 对每个请求验证令牌。切勿仅信任客户端验证。
- 检查其他字段。指定时,验证操作 (action) 和域名 (hostname)。
- 监控滥用并记录失败的验证以及异常模式。
- 使用 HTTPS。始终在安全连接上进行验证。
- 仅在您的后端环境中调用 Siteverify API。如果您在前端客户端代码中公开密匙来调用 Siteverify,攻击者可以绕过安全检查。请确保您的客户端代码将验证令牌发送到您的后端,并且您的后端是 Siteverify API 的唯一调用者。
- 设置合理的超时时间。不要无限期等待 Siteverify 响应。
- 实施重试逻辑并处理临时网络问题。
- 如果您的流程需要,可以为相同的令牌缓存验证结果。
- 监控您的 API 延迟。跟踪 Siteverify 的响应时间。
- 为 API 故障准备回退行为。
- 使用对用户友好的消息。不要向用户暴露内部错误细节。
- 妥善记录错误以进行调试,而不要暴露敏感信息。
- 进行速率限制,以防止验证请求泛滥。