KernelRanch
返回文章列表

Turnstile 人机验证:前端一个 div,后端一次 POST

2026-11-10 #Cloudflare

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-responsetoken 无效检查 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-WebRequest POST 到 https://challenges.cloudflare.com/turnstile/v0/siteverify,分别用"始终通过"和"token 已用过"两个官方测试密钥,返回体原样贴在文中,时间 2026-11-10。
  • 未执行:无 Zone 权限,Turnstile 站点未在控制台创建,真实 sitekey 与 Secret 未生成;前端嵌入与 Workers 后端代码片段未部署运行。
  • 未验证:token 300 秒有效期与"一次性"依据文档;文中第二次实测的 timeout-or-duplicate 是用测试密钥模拟的,不是真实 token 重放。