Turnstile 人机验证:前端一个 div,后端一次 POST
Turnstile 是 Cloudflare 的验证码替代品。它不弹图片选择题,多数情况下用户在页面上看不到任何东西:脚本在后台跑一段浏览器检查,通过后往表单里塞一个 token,提交时你把这个 token 拿到服务端验证一次。它免费,而且不要求站点接入 Cloudflare——域名在别处也能用。
收费与接入门槛低,代价是它只解决"这个请求背后是不是浏览器"这一个问题。它挡不住撞库(密码对了照样进)、挡不住接口滥用(绕过前端直接打 API),也挡不住有真人参与的刷单。把它放在正确的位置上,比指望它做全站防护有用。
前置:一个能改前端和后端的站点。用 Workers 承载后端的做法见 54-workers-fetch-handler-tutorial.md。
1. 三种控件模式
在控制台 Turnstile → Add site 时选模式,之后也能改。
| 模式 | 用户看到什么 | 适用 |
|---|---|---|
| Managed | 通常什么都不显示,可疑时才出勾选框 | 默认选择,登录、注册、评论 |
| Non-interactive | 不显示勾选框,后台完成检查 | 表单较长、不想打断填写 |
| Invisible | 完全不可见,由脚本触发 | 需要自己控制触发时机,例如提交按钮点击时 |
Managed 是官方推荐的默认值。它会在风险高时自动升级为交互式挑战,等于把"要不要打扰用户"的判断交给 Cloudflare。
2. 建站点拿两个 key
控制台 Turnstile → Add site,填:
- Site name:随便起,只用于控制台显示。
- Domain:填实际使用的前端域名,可以加多个(
example.com、www.example.com)。域名不匹配会导致验证失败。 - Widget mode:先选 Managed。
建完拿到 Sitekey(公开,放前端)与 Secret key(保密,只放服务端)。Secret 泄露等于验证形同虚设,放环境变量里,别写进前端仓库。Workers 的密钥管理见 64-workers-env-secrets.md。
3. 前端嵌入
页面里加脚本与容器,两个位置:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form action="/api/comment" method="post">
<textarea name="content"></textarea>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA你的sitekey" data-theme="light"></div>
<button type="submit">提交</button>
</form>
隐式渲染方式下,Turnstile 会自动找到 class="cf-turnstile" 的容器,并在表单里注入一个名为 cf-turnstile-response 的隐藏字段。表单以传统方式提交时,服务端直接从这个字段取值。
如果是单页应用或需要自己控制,改用显式渲染:
const widgetId = turnstile.render("#turnstile-box", {
sitekey: "0x4AAAAAAA你的sitekey",
callback: (token) => { window.__tsToken = token; },
"expired-callback": () => { window.__tsToken = null; }
});
token 有效期 300 秒,过期后要重新拿。提交失败(例如后端返回校验错误)时,调用 turnstile.reset(widgetId) 换一个新 token,否则复用同一个 token 会拿到 duplicate 错误。
4. 后端校验
服务端向 https://challenges.cloudflare.com/turnstile/v0/siteverify 发一次 POST:
const form = await request.formData();
const token = form.get("cf-turnstile-response");
const body = new FormData();
body.append("secret", env.TURNSTILE_SECRET);
body.append("response", token);
body.append("remoteip", request.headers.get("cf-connecting-ip") ?? "");
const vr = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body
});
const result = await vr.json();
if (!result.success) {
return new Response("verification failed: " + result["error-codes"].join(","), { status: 403 });
}
返回体里有三个字段值得看:success、error-codes、hostname。hostname 是 token 签发时的主机名,可以拿它跟你期望的域名比对,防止有人把 sitekey 抄到别的站上刷。
本机用官方测试密钥真实调用了一次,返回如下:
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
secret=1x0000000000000000000000000000000AA -> 200
{"challenge_ts":"2026-11-10T06:43:26.790Z","error-codes":[],"hostname":"example.com",
"metadata":{"result_with_testing_key":true},"success":true}
换"token 已用过"的测试密钥:
secret=3x0000000000000000000000000000000AA -> 200
{"error-codes":["timeout-or-duplicate"],"success":false,"messages":[],
"metadata":{"result_with_testing_key":true}}
第二次调用说明两件事:失败时 HTTP 状态码仍是 200,判断成败只能看 success 字段;token 是一次性的,重复提交会拿到 timeout-or-duplicate。
常见错误码与处理:
| error-codes | 含义 | 处理 |
|---|---|---|
| missing-input-response | 表单里没带 token | 检查前端容器与字段名 |
| invalid-input-response | token 无效 | 检查 sitekey 与域名是否匹配 |
| timeout-or-duplicate | 已用过或超过 300 秒 | 重置控件,让用户重新提交 |
| sitekey-secret-mismatch | 两个 key 不属于同一站点 | 检查环境变量是否串了 |
| internal-error | 服务端临时故障 | 按业务决定放行还是提示重试 |
5. 放在哪里最划算
| 位置 | 效果 | 备注 |
|---|---|---|
| 登录 / 注册 | 挡批量撞库与批量注册 | 配合速率限制,见 78-rate-limiting.md |
| 评论 / 留言 | 挡垃圾评论机器人 | 与关键词过滤叠加 |
| 找回密码 | 挡短信与邮件轰炸 | 成本敏感,优先加 |
| 公开 API | 不合适 | 客户端无法跑控件,改用 API Key 与限流 |
| 全站每个请求 | 不合适 | 每页都要验证,体验与性能都受损 |
登录接口是性价比最高的位置:一次验证挡掉整批撞库,而正常用户一天只登录一两次。
控件脚本用 async defer 加载,不阻塞页面渲染;首次会在 challenges.cloudflare.com 建一个连接,别把它放在首屏关键路径上。移动端弱网下控件偶尔会延迟出现,表单提交按钮在 token 就绪前禁用,比提交后报错体验好。
常见问题
开了严格 CSP 之后控件加载不出来怎么办?
需要放行 https://challenges.cloudflare.com,至少覆盖 script-src 与 frame-src(控件用 iframe 渲染)。配置方法见 83-security-headers-hsts.md。控制台报错可在浏览器 console 里看到被 CSP 拦掉的请求。
Turnstile 能替代 WAF 吗?
不能。Turnstile 只在你放控件的位置生效,且依赖用户走前端。直接打 API 的攻击者完全绕开它。WAF 与速率限制解决的是另一层,见 77-waf-basics-free-rules.md。
测试密钥能上线吗?
不能。1x/2x/3x 开头的测试 key 对任何 token 都返回固定结果,等于没有验证。它们的用途是本地跑通链路与写自动化测试。
小结
Turnstile 的接入成本是前端一个 div 加后端一次 POST,免费且不绑定 Cloudflare 域名。真正需要想清楚的是放哪里:登录、注册、评论、找回密码这类"一次验证挡一批"的位置收益最高,公开 API 与全站强制验证都不合适。
两个容易踩的细节:token 单次有效 300 秒,重复提交返回 timeout-or-duplicate;失败响应的 HTTP 码是 200,只判断状态码会把失败当成功。
数据来源与检索时间
检索时间 2026-11-10。
| 来源 | URL | 对应数据 |
|---|---|---|
| Turnstile 测试密钥文档 | https://developers.cloudflare.com/turnstile/troubleshooting/testing/ | 测试 sitekey(1x/2x/3x 开头)与测试 secret 的行为、data-sitekey 用法 |
| Turnstile 文档 | https://developers.cloudflare.com/turnstile/ | 控件模式、siteverify 接口、错误码 |
| Cloudflare 定价页 | https://www.cloudflare.com/plans/ | Turnstile 免费提供 |
核验说明
- 两次 siteverify 调用为本机真实执行:
Invoke-WebRequestPOST 到https://challenges.cloudflare.com/turnstile/v0/siteverify,分别用"始终通过"和"token 已用过"两个官方测试密钥,返回体原样贴在文中,时间 2026-11-10。 - 未执行:无 Zone 权限,Turnstile 站点未在控制台创建,真实 sitekey 与 Secret 未生成;前端嵌入与 Workers 后端代码片段未部署运行。
- 未验证:token 300 秒有效期与"一次性"依据文档;文中第二次实测的
timeout-or-duplicate是用测试密钥模拟的,不是真实 token 重放。