后端校验与集成
前端的 widget 只是给用户看的一个组件,真正的安全防线在后端。用户提交表单时带来的 cf-turnstile-response token 必须由后端拿去 Cloudflare 校验一次,确认是真人才放行。这篇讲清楚整个校验流程,从接口调用到表单集成,再到生产环境要注意的几个坑。
校验流程全貌
整个 Turnstile 校验链路分四步
| 步骤 | 执行方 | 动作 |
|---|---|---|
| 第一步 | 前端浏览器 | 加载 widget,用户通过验证拿到 token |
| 第二步 | 前端浏览器 | 表单提交时把 token 随其他字段发给业务后端 |
| 第三步 | 业务后端 | 用密钥把 token 发到 Cloudflare siteverify 接口 |
| 第四步 | 业务后端 | 根据 siteverify 返回的 success 字段决定放行或拒绝 |
要特别强调的是第三步必须由后端做,不能由前端做。前端拿不到密钥,且前端校验可被绕过,攻击者直接构造请求跳过 widget。后端校验是唯一可信的环节。
siteverify 接口
Cloudflare 提供一个公开的校验接口
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
请求体是表单格式,两个字段
| 字段 | 类型 | 说明 |
|---|---|---|
| secret | string | 站点密钥,从 Cloudflare 控制台拿到 |
| response | string | 前端提交上来的 token |
| remoteip | string | 可选,访客 IP,用于额外风控 |
remoteip 不是必填,但建议带上,Cloudflare 会用它做额外的异常检测,比如同一 IP 短时间内大量 token 就会被识别为可疑。
返回是 JSON
{
"success": true,
"challenge_ts": "2026-08-17T01:23:45.678Z",
"hostname": "example.com",
"error-codes": [],
"action": "login",
"cdata": "session-abc"
}
字段含义
| 字段 | 说明 |
|---|---|
| success | 布尔值,true 表示 token 有效 |
| challenge_ts | 验证时间戳,ISO 8601 格式 |
| hostname | 触发验证的域名,用于核对是否真的是你的站点 |
| error-codes | 错误码数组,校验失败时会有内容 |
| action | 你在 widget 上配置的 action 值,原样回传 |
| cdata | 你在 widget 上配置的 cdata 值,原样回传 |
Node 服务端校验示例
最简单的实现,用原生 fetch 调用
async function verifyTurnstile(token, remoteip) {
const formData = new URLSearchParams();
formData.append('secret', process.env.TURNSTILE_SECRET);
formData.append('response', token);
if (remoteip) formData.append('remoteip', remoteip);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await res.json();
return data;
}
在 Express 路由里用
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.post('/login', async (req, res) => {
const token = req.body['cf-turnstile-response'];
if (!token) {
return res.status(400).send('缺少验证码 token');
}
const remoteip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const result = await verifyTurnstile(token, remoteip);
if (!result.success) {
return res.status(403).send('验证码校验失败 ' + result['error-codes'].join(','));
}
if (result.hostname !== 'example.com') {
return res.status(403).send('来源域名不匹配');
}
// 校验通过,继续业务逻辑
res.send('登录成功');
});
app.listen(3000);
注意三层校验缺一不可,success 为真、hostname 匹配、error-codes 为空。只看 success 不够,攻击者可能用别处搞来的合法 token 来打你的接口。
action 与 cdata
这两个字段用于把 widget 和具体业务绑定,防止 token 被跨场景复用
| 字段 | 用途 | 校验方式 |
|---|---|---|
| action | 标识这次验证是哪个动作,比如 login、register | 后端比对返回值与期望值是否一致 |
| cdata | 透传业务自定义数据,比如会话 ID、订单号 | 后端核对值是否是本次请求生成的 |
前端配置
turnstile.render('#container', {
sitekey: '0x4AAAAAAABBBBBBBBBBBB',
action: 'login',
cdata: 'session-abc123',
});
后端校验时多一道检查
if (result.action !== 'login') {
return res.status(403).send('action 不匹配');
}
if (result.cdata !== expectedCdata) {
return res.status(403).send('cdata 不匹配');
}
action 是公开字符串,主要作用是分类审计。cdata 才是真正的防复用手段,每个会话生成一个随机值,后端校验时确认它属于当前会话,这样即使 token 被截获也无法在别的会话里用。
表单集成完整示例
把前端和后端串起来。前端页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>注册</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<form id="register-form" action="/register" method="post">
<input type="email" name="email" placeholder="邮箱" required />
<input type="password" name="password" placeholder="密码" required />
<div class="cf-turnstile"
data-sitekey="0x4AAAAAAABBBBBBBBBBBB"
data-callback="onTurnstileSuccess"></div>
<button type="submit" id="submit-btn" disabled>注册</button>
</form>
<script>
function onTurnstileSuccess(token) {
document.getElementById('submit-btn').disabled = false;
}
document.getElementById('register-form').addEventListener('submit', (e) => {
const token = document.querySelector('input[name="cf-turnstile-response"]').value;
if (!token) {
e.preventDefault();
alert('请先完成验证');
}
});
</script>
</body>
</html>
后端用上面那段 Express 代码,把 /login 换成 /register 就行。关键点是按钮默认 disabled,验证通过后 callback 才解锁,避免用户没验证就提交。
常见错误码
| 错误码 | 含义 | 处理建议 |
|---|---|---|
| missing-input-secret | 没传 secret | 检查环境变量是否注入 |
| invalid-input-secret | secret 错误 | 核对控制台复制的密钥 |
| missing-input-response | 没传 token | 前端没拿到 token,检查 widget 渲染 |
| invalid-input-response | token 错误或已过期 | 让用户重新验证 |
| bad-request | 请求格式不对 | 检查是否用表单格式提交 |
| timeout-or-duplicate | token 已被使用过或超过 300 秒 | reset widget 重新拿 token |
| internal-error | Cloudflare 内部错误 | 重试,持续报错联系官方 |
timeout-or-duplicate 是最常遇到的,原因要么是 token 已被校验过一次,要么是提交时已超过有效期。前端发现这类错误要主动 turnstile.reset 重新生成 token。
生产环境注意事项
第一,密钥管理。secret 千万不能写进代码仓库,用环境变量或密钥管理服务注入。Workers(Cloudflare 的边缘计算函数服务)可以用 wrangler secret put 注入,Node 服务用 dotenv 或部署平台的密钥配置。
第二,校验超时。siteverify 接口偶尔会慢,建议给请求加 5 秒超时,超时后让用户重试,不要无限等待卡死请求。
第三,失败处理策略。校验失败时不要直接 500,要返回明确的 403 并提示重新验证。前端拿到 403 要 reset widget 让用户再来一次。
第四,监控错误码。把 error-codes 记进日志并做告警,如果某个错误码短时间激增,可能是被刷或配置出错。
第五,IP 限流。Turnstile 防的是机器人验证,但不防接口被高频调用。配 remoteip 同时还要在网关层做限流,比如单 IP 每分钟最多 10 次登录请求。
小结
后端校验的核心是调 siteverify 接口拿到 success,再叠加 hostname、action、cdata 三层核对才能放行。token 一次性、有效期 300 秒,失败时让前端 reset 重新生成。生产环境务必把密钥放环境变量、给请求加超时、记录错误码日志。前后端串起来就是一个完整的注册登录防刷方案,比 reCAPTCHA 体验好太多。
上一篇 验证码基础与前端嵌入