如果你的服务端校验一直返回 cf-turnstile-response invalid,你并不孤单。这是开发者在接入 Cloudflare Turnstile 时最常遇到的失败之一,而令人沮丧的是:同一段代码常常上一分钟还能用,下一分钟就失败了。你的小组件生成了 token(也就是 cf-turnstile-response 的值),它到达了你的后端,你把它 POST 到了 Cloudflare 的 /siteverify 接口,结果返回 success: false,并带有诸如 timeout-or-duplicate、invalid-input-response 或 invalid-input-secret 之类的错误码。在客户端,你可能还会看到通用的 600010 challenge 错误。
好消息是:一个无效的 cf-turnstile-response 几乎总能追溯到一份很短、且可修复的根因清单。本指南会逐一讲解每种根因、给出确切的修复方法,并在最后展示自动化场景的可靠方案——也就是当你每次都需要以编程方式获取一个全新有效 token 的时候。
cf-turnstile-response 字段到底是什么
当 Turnstile 小组件在浏览器中运行时,它会执行一次轻量级的 challenge 并生成一个一次性 token。该 token 会被写入一个名为 cf-turnstile-response 的隐藏表单输入框中(同时也会传给你的 JavaScript 回调函数)。它就是“真实浏览器完成了 challenge”的凭证。
你的后端必须取得这个 token,并在服务端进行校验:连同你的 secret key 一起 POST 到 https://challenges.cloudflare.com/turnstile/v0/siteverify。Cloudflare 返回 JSON:成功时为 { "success": true, ... },token 被拒时为 { "success": false, "error-codes": [...] }。所谓 “cf-turnstile-response invalid” 问题,本质上就是一次非成功的 siteverify 响应,而 error-codes 数组会准确告诉你原因。
根因 #1:token 已过期或已被使用(timeout-or-duplicate)
这是 turnstile token 无效最常见的原因。Turnstile token 是单次使用且生命周期很短的:每个 token 大约有效 300 秒(5 分钟),且只能被校验一次。提交两次,或在有效窗口关闭之后再提交,siteverify 就会返回 error-codes: ["timeout-or-duplicate"]。
在现实中它出现的频率比你想象的更高:用户打开表单后分了神、上传了一个很大的附件,或在缓慢的网络上提交,结果 token 在传输途中就过期了。又或者你的代码对失败请求做了重试,意外地用同一个 token 重新校验了一次。再或者一个被双击的提交按钮发出了两个请求。
- token 过期后,用 JavaScript 回调 turnstile.reset() 刷新小组件(或重新渲染一个新组件),确保用户始终提交的是当前有效的 token。
- 每个 token 只校验一次。永远不要用同一个 cf-turnstile-response 调用 /siteverify 两次。
- 首次点击后禁用提交按钮,避免重复 POST。
- 如果表单可能长时间停留打开,设置 expired-callback,在提交前重新执行 challenge。
根因 #2:sitekey 与 secret 不匹配(invalid-input-secret)
你的前端用一个公开的 sitekey 渲染小组件;你的后端用一个私有的 secret key 进行校验。这两者必须属于同一个 Cloudflare 账户下的同一个 Turnstile 小组件。如果你把一个组件的 sitekey 和另一个组件的 secret 混用,或者把测试环境和生产环境的密钥弄反了,那么每一次校验都会失败。此时经典的 siteverify 错误码是 invalid-input-secret(当 secret 为空时则是 missing-input-secret)。
修复方法:打开 Cloudflare 控制台,找到确切的那个小组件,从同一个组件中复制 sitekey 和 secret。把 secret 存进环境变量,并确认它在运行时确实被加载了(环境变量缺失是一个出人意料地常见的元凶)。同时确保你是把 secret 作为 secret 这个表单字段发送的,而不是误发了 sitekey。
根因 #3:hostname、action 或 cData 不匹配
一个 Turnstile 小组件会绑定到你为它配置的域名。如果渲染该组件的页面所在的 hostname 不在组件允许的范围内,token 就会被拒。siteverify 响应中包含 hostname 字段,以及 action 和 cdata 字段,你可以也应该把它们与你的预期进行比对。
需要检查的三类不匹配。第一,hostname:确保提供该组件的域名(包括子域名以及开发期间的 localhost)在组件的允许列表中,并把返回的 hostname 与你自己的域名做对比。第二,action:如果你在组件上设置了 data-action,校验 siteverify 返回的 action 是否一致;不一致就按失败处理。第三,cData:如果你通过 data-cdata 传入了自定义数据,校验返回的 cdata。这些检查还能加固防御,避免 token 在不同接口之间被重复使用。客户端的 600010 错误经常正是源于这一类配置错误(域名未被允许、为该环境使用了错误的密钥)。
根因 #4:测试用 dummy 密钥、幂等性与时钟偏差
有几个更隐蔽的原因常让经验丰富的开发者也措手不及。
在生产环境使用测试 sitekey:Cloudflare 的 dummy 密钥(例如永远通过的 sitekey 1x00000000000000000000AA)会生成一个 dummy token。真实的生产 secret key 会拒绝这个 dummy token,反之亦然——测试用的 secret 会拒绝真实 token。如果你在构建中留下了测试密钥,请把它换成你真实组件的密钥。
idempotency_key 复用:siteverify 接受一个可选的 idempotency_key,这样你就能安全地对同一个 token 重试校验并得到缓存结果。但如果你对一个不同的 token 复用了同一个 idempotency_key,你拿回的会是缓存的(旧的)结果,看起来就像一个错误或无效的响应。请为每个 token 使用一个全新的 idempotency_key,或者干脆不传。
时钟偏差:token 过期是基于时间的,因此服务器时钟漂移会让全新的 token 看起来已过期(或错误地延长有效窗口)。请用 NTP 保持服务器时间同步。
快速排查清单
在改代码之前,先读一读你 siteverify 响应中的 error-codes 数组。它通常会直接点明问题所在:
- timeout-or-duplicate -> token 已过期(超过约 300 秒)或被校验了不止一次。换一个全新 token;只校验一次。
- invalid-input-response -> cf-turnstile-response 格式错误、为空,或在传输途中过期。确认该字段确实被发送了。
- invalid-input-secret / missing-input-secret -> secret 错误或缺失。重新复制对应组件的 secret。
- 客户端显示 600010 -> 环境/配置问题(为该域名使用了错误密钥、域名未被允许、被浏览器扩展拦截)。重新核对密钥和允许的 hostname。
当你需要以编程方式获取有效 token 时:NSLSolver
以上所有内容修复的都是面向真人的合法集成。但如果你在做自动化——运行爬虫、机器人、针对第三方站点的集成测试,或压测工具——那就没有真人来完成这个小组件,你也无法自己铸造一个有效的 cf-turnstile-response。你需要一个能驱动真实 challenge、并返回一个可供你提交的全新一次性 token 的服务。
这正是 NSLSolver 所做的。你把目标站点的 sitekey 和 URL POST 到 /solve,它会返回一个有效的 Turnstile token,你像浏览器一样把它用作 cf-turnstile-response 的值。token 平均约在 250ms 内返回,成功率 99.9%,且失败的求解不计费。Turnstile 求解价格为每 1,000 次 0.40 美元,按量付费,新账户在注册时可获得 100 次免费请求,无需信用卡、无需加密货币即可开始。
由于 NSLSolver 返回的每个 token 都是全新的,只要你及时提交(在约 300 秒的窗口内)且只提交一次,你就能彻底绕开单次使用和过期的问题。
下面是一个完整的 Python 示例,它先求解 Turnstile challenge,然后把 token 提交到目标表单:
import requests
NSL_API_KEY = "nsl_YOUR_API_KEY"
TARGET_URL = "https://target-site.com"
SITE_KEY = "0x4AAAAAAA..." # the data-sitekey on the target page
# 1) Ask NSLSolver for a fresh, valid Turnstile token
resp = requests.post(
"https://api.nslsolver.com/solve",
headers={"X-API-Key": NSL_API_KEY},
json={
"type": "turnstile",
"site_key": SITE_KEY,
"url": TARGET_URL,
},
timeout=120,
)
resp.raise_for_status()
token = resp.json()["token"] # e.g. "0.AAAA..."
# 2) Submit the token as the cf-turnstile-response value
# Do this promptly (within ~300s) and only once per token.
submit = requests.post(
f"{TARGET_URL}/login",
data={
"username": "[email protected]",
"password": "secret",
"cf-turnstile-response": token,
},
timeout=30,
)
print(submit.status_code, submit.text[:200])