六维教程

后端校验与集成

前端的 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 体验好太多。

上一篇 验证码基础与前端嵌入

上一篇
验证码基础与前端嵌入
下一篇
向量索引与相似度搜索