留言板一夜被刷两千条:用 Turnstile + Worker 做无感人机验证(2026)

Turnstile 是 Cloudflare 提供的无感人机验证:访客通常只需点击一个复选框,甚至完全不用操作,前端拿到一个一次性 token,后端调用 siteverify 接口校验通过才放行。 它替代的是 reCAPTCHA 那套「选红绿灯」的交互,但校验逻辑必须放在服务端——只在前端判断 success 等于没做。我这套组合上线后,同一个留言板的模板化垃圾提交基本清零,只剩下零星几条手工广告,正常用户的额外操作是点一下方块。整套接入需要三样东西:一个 Turnstile widget(拿到 site key 和 secret key)、一段前端脚本、一个调用 siteverify 的 Worker 或后端接口。

上面是结论。上一篇写 Cloudflare WAF + Rate Limiting 时,挡的是「同一个 IP 高频刷」;这篇处理的是另一种对手——它慢、它分散、它内容垃圾,频次规则对它几乎无效。

Turnstile 校验链路

Turnstile 和 reCAPTCHA 有什么不一样?

结论:Turnstile 免费、不依赖 Google、不追踪用户画像,而且提供了可以在本地和 CI 里用的测试密钥——这三点让它比 reCAPTCHA 更适合自建站。

维度 reCAPTCHA v3 Turnstile
费用 免费额度有限,超量按次计费 免费,官方标称不限量
依赖 Google 服务,国内访问困难 Cloudflare 边缘,国内可达性更好
用户交互 无感打分,失败降级到挑战 无感 + 可选交互式复选框
测试密钥 有,但文档分散 官方提供三组固定测试 key
数据去向 提交给 Google 用于风控模型 只在 Cloudflare 侧校验

对国内访问为主的站点,reCAPTCHA 的脚本本身就经常加载失败,这是最实际的差别。Turnstile 的 JS 走 challenges.cloudflare.com,可用性明显好一些。

整体怎么接?

接入五步

第一步:建 widget,拿两个 key

结论:一个 widget 对应一个域名列表,site key 可以公开,secret key 只能放服务端环境变量里。

在 Cloudflare 控制台左侧进 Turnstile,点 Add widget:

  • Widget name:填站点名,方便区分;
  • Hostname:填你的域名,可以加多条;本地开发可以加 localhost;
  • Widget Mode:先选 Managed(推荐),它会自动在无感和交互之间切换。

建完会给你 Site Key(公开,放前端)和 Secret Key(保密,放 Worker 的 secret)。Secret Key 只在创建时完整显示一次,页面刷新就看不到全部了——先存进密码管理器,弄丢了只能重建 widget 再改一遍前端。

官方文档在 Cloudflare Turnstile · Get started,里面有不同前端框架的接入示例。

步骤 1:创建 widget

第二步:前端渲染,别自己拼 hidden input

结论:用官方脚本 + cf-turnstile class 的方式最省事,token 会自动注入到一个名为 cf-turnstile-response 的隐藏字段里。

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form method="POST" action="/api/comment">
  <textarea name="content" required></textarea>
  <div class="cf-turnstile" data-sitekey="0x4AAAAAAA...你的sitekey"></div>
  <button type="submit">提交</button>
</form>

如果你的表单是 AJAX 提交(现在大部分都是),需要手动取 token:

const token = document.querySelector('[name="cf-turnstile-response"]').value;
await fetch("/api/comment", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ content, token }),
});

这里有个必须知道的行为:token 是一次性的,校验过一次就失效。 AJAX 表单提交失败后如果用户再点一次,拿到的还是旧 token,服务端一定返回 timeout-or-duplicate。解决办法是每次提交失败后调用 turnstile.reset():

if (!res.ok) {
  turnstile.reset();   // 重新挑战,换新 token
}

这个坑我踩了整整一个下午。现象是「第一次提交成功,第二次永远失败」,日志里全是 timeout-or-duplicate,一开始还怀疑是 Worker 缓存了响应。

步骤 2:前端接入

第三步:服务端校验,这一段才是关键

结论:校验就是一次 POST 到 siteverify,把 secret、token、可选 remoteip 传过去,看返回的 success 和 hostname。

Worker 里可以这样写:

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const { token, content } = await request.json();
    if (!token) {
      return Response.json({ ok: false, error: "missing-token" }, { status: 400 });
    }

    const form = new FormData();
    form.append("secret", env.TURNSTILE_SECRET);
    form.append("response", token);
    form.append("remoteip", request.headers.get("CF-Connecting-IP") || "");

    const verify = await fetch(
      "https://challenges.cloudflare.com/turnstile/v0/siteverify",
      { method: "POST", body: form }
    );
    const result = await verify.json();

    if (!result.success) {
      return Response.json(
        { ok: false, error: result["error-codes"] },
        { status: 403 }
      );
    }
    // 域名兜底校验:防止别人把你的 sitekey 嵌到自己站上刷
    if (result.hostname !== "pddpdd.cn") {
      return Response.json({ ok: false, error: "hostname-mismatch" }, { status: 403 });
    }

    // 校验通过,走真正的业务逻辑
    return Response.json({ ok: true });
  },
};

secret 用 wrangler 存,别写进代码:

$ npx wrangler secret put TURNSTILE_SECRET

siteverify 的返回结构在 官方文档 · Server-side validation 里列全了:success、error-codes、challenge_ts、hostname、action、cdata。生产环境要落日志的是 error-codes,它直接告诉你失败原因。

步骤 3:Worker 校验

第四步:用测试 key 把流程跑通

结论:Cloudflare 给了三组固定测试密钥,可以在不产生真实挑战的情况下验证前后端逻辑是否打通。

site key secret key 行为
1x00000000000000000000AA 1x0000000000000000000000000000000AA 永远通过
2x00000000000000000000AB 2x0000000000000000000000000000000AA 永远失败
3x00000000000000000000FF 3x0000000000000000000000000000000AA 强制显示交互挑战

把 secret 换成第二组,你的「校验失败」分支立刻就能被测到,不用等真机器人。这比自己在代码里写 if (env.DEBUG) return true 干净得多——调试开关忘了关就是生产事故。

$ curl -s -X POST https://challenges.cloudflare.com/turnstile/v0/siteverify \
    -d "secret=1x0000000000000000000000000000000AA" \
    -d "response=XXXX.DUMMY.TOKEN.XXXX"
{"success":true,"error-codes":[],"challenge_ts":"...","hostname":"dummy-key-pass"}

步骤 4:测试密钥验证

第五步:把失败姿态摆对

结论:校验服务本身不可用时,选择「放行 + 打标」还是「拦截」,取决于你的业务能不能接受脏数据——留言板可以放行后人工审,注册和支付必须拦。

我这条 Worker 的处理分三层:

  1. result.success === false → 403,直接拒;
  2. fetch 抛异常(网络问题、Cloudflare 侧 5xx)→ 放行但写入 flagged: true,进后台待审队列;
  3. hostname 不匹配 → 403,这是最明确的滥用信号。

第三条经常被忽略。别人完全可以把你的 site key 抄走嵌到自己的垃圾站上,token 是合法签发的,服务器校验也会 success: true——只有 hostname 能识别出「这个 token 不是给我这个域名签的」。

另外提醒一句:Turnstile 不能替代服务端的内容过滤。 真人手工发广告它拦不住。它的定位是「把机器挡在门外」,进门之后的垃圾还得靠关键词、频率、人工审核。

步骤 5:失败策略

踩过的坑,一共五条

结论:五个坑里,token 一次性、CSP 拦截、hostname 校验这三条最常见,也最容易在「本地测通了、上线就废」时被误判成别的问题。

坑一:token 一次性,复用必失败。 报错 timeout-or-duplicate,原因见上文,AJAX 表单要 turnstile.reset()。

坑二:CSP 把它拦了。 如果站点上了严格的 Content-Security-Policy,脚本会被静默拦截,表现是「验证框根本不显示」。需要放行:

script-src 'self' https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
connect-src 'self' https://challenges.cloudflare.com;

frame-src 这条最容易漏,因为 Turnstile 的交互挑战是放在 iframe 里的。

坑三:token 有效期 300 秒。 用户打开页面去泡了杯咖啡再提交,token 已经过期。长表单(比如写一篇长评论)要监听 expired-callback 并自动 reset。

坑四:本地开发拿不到 token。 localhost 必须在 widget 的 hostname 列表里,否则脚本不渲染。或者干脆本地用「永远通过」的测试 key。

坑五:只在前端判断。 这是最致命的。前端 data-callback 只是拿到 token,真正的判定必须在服务端。很多人接完发现「还是被刷」,就是因为后端压根没校验。

效果与成本

结论:免费额度对个人站完全够用,真正的成本是那几十行校验代码和一次前端改造。

先说清楚这套东西能挡什么、挡不住什么。上线前那一周的垃圾提交有两个特征:模板高度一致(同一套外链、同一段话术)、来源 IP 分散在大量不同网段、单个 IP 每小时最多两三次。这三个特征叠在一起,正好说明它是低频分布式提交,Rate Limiting 的「单位时间次数」阈值对它几乎无效——你调到能拦住它的阈值,正常用户也会被误伤。接上 Turnstile 之后,同一套模板的提交基本消失,剩下的是零星的手工广告,那部分只能靠人工审核。

用户侧的感受:Managed 模式下大部分人连复选框都看不到,页面加载多了一个 JS 文件(约 60KB,走 Cloudflare 边缘,通常已缓存)。代价是移动端弱网下会多一次资源请求,如果站点本身首屏预算很紧,记得把这个脚本放到表单附近的懒加载里,别塞进 <head> 阻塞渲染。

常见问题

Q:Turnstile 收费吗?
A:截至 2026 年官方标称免费且不限量,企业版才涉及 SLA 等增值项。个人站不用担心额度。

Q:国内访问 Turnstile 脚本会不会加载不出来?
A:challenges.cloudflare.com 在国内可达性比 Google 的域名好很多,但如果你的用户大量在特定网络环境,建议做降级:脚本加载失败时隐藏验证框,改走服务端限频 + 人工审核。

Q:一个站点要建几个 widget?
A:按「业务入口」分,留言板和注册页建议分开,这样后台能看到各自通过率,出问题也能单独下线一个。

Q:Worker 免费额度够吗?
A:免费版每天 10 万次请求。校验逻辑只多一次子请求,个人站通常每天几十到几百次提交,远远用不完。

Q:能同时防刷接口和防垃圾评论吗?
A:能,但两者要配合。Turnstile 挡机器,Rate Limiting 挡频次,内容过滤挡真人广告,三层各管一段。

相关阅读

上一篇 Claw 免费容器部署实战合集:图床、QQ 机器人、Grok 网关、Web 笔记本