接入细节与进阶

快速接入给出了可以直接运行的最小示例。这一页说明正式上线前需要处理的细节:数据存在哪里、怎样安全地重试、组件回调、同页多个组件,以及可选的挑战豁免。示例中的服务地址是托管服务 https://captcha.moe;自己部署时换成你的服务地址。

从其他验证码服务切换,请同时阅读迁移指南

为每次操作保存一条记录

每次受保护的操作(一次注册、一次登录)都在服务端保存一条记录,至少包括:

字段说明
会话这条记录属于哪个服务端会话(已登录或匿名)
nonce创建 intent 用的随机值,16–128 位字母、数字、-_
intent_id/intents 返回的许可 ID
action业务动作名,例如 signup
hostname表单所在域名,来自你的配置,不要取自请求的 Host 头
idempotency_key兑换用的幂等键,规则同 nonce,与 nonce 分开生成

这些值都由服务端决定。浏览器只提交 token(表单字段 moe-captcha-response),不能指定或替换其余任何一项。下面代码里的 record 就是这条记录。

创建 intent

const CAPTCHA_API = 'https://captcha.moe';

const response = await fetch(`${CAPTCHA_API}/intents`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    authorization: `Bearer ${process.env.CAPTCHA_SECRET}`,
  },
  body: JSON.stringify({ action: record.action, nonce: record.nonce, hostname: record.hostname }),
  signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`无法创建验证:${response.status}`);
const intent = await response.json();
// 原子地把 intent.intent_id 写回 record,不要覆盖另一个已关联的许可
  • 许可 5 分钟内有效,只能兑换一次。
  • 创建请求超时或网络失败时,用同一个 nonce 和完全相同的字段重试,会拿回同一个许可,不会产生第二个。
  • nonce 曾用不同字段创建过会返回 nonce-conflict;原许可已过期或已兑换会返回 intent-expired-or-consumed,这时生成新的 nonce。

把组件放进页面

  • 由服务端模板填入 sitekey 和 intent_id,并做 HTML 属性转义。
  • 带许可的页面是个性化内容:响应头加 Cache-Control: no-store,不要放进共享缓存或 CDN 缓存。
  • CSP 需要允许 script-src https://captcha.moeconnect-src https://captcha.moe,推荐允许 worker-src blob:。详见组件配置
  • 需要增强信号采集时使用 /widget-risk.js,并在你的隐私说明中告知访问者。

兑换 token,再执行业务

const token = form.get('moe-captcha-response');
if (!token) throw new Error('请先完成验证');

const response = await fetch(`${CAPTCHA_API}/siteverify`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    authorization: `Bearer ${process.env.CAPTCHA_SECRET}`,
  },
  body: JSON.stringify({
    response: token,
    intent_id: record.intent_id,
    action: record.action,
    hostname: record.hostname,
    idempotency_key: record.idempotency_key,
  }),
  signal: AbortSignal.timeout(5000),
});
const result = await response.json().catch(() => ({}));
if (response.status === 429 || response.status >= 500) {
  // 暂时不可用:保留这次提交,稍后用相同参数和幂等键重试
}
if (response.status !== 200 || result.success !== true ||
    result.intent_id !== record.intent_id ||
    result.action !== record.action || result.hostname !== record.hostname) {
  // 验证未通过:让访客重新验证(创建新的 intent,重新渲染组件)
}
// 验证通过:在一个数据库事务里执行业务,并保存 result.decision_id
  • 首次成功兑换扣减额度。10 分钟内用相同参数和同一个幂等键重试,会拿到同一个结果,不会重复扣费。网络失败时不要更换幂等键。
  • 你的业务操作也要幂等:用 intent_id 或 decision_id 建唯一约束,保证并发或重复提交只注册一次、只下单一次。验证码的幂等不等于你的业务已经幂等。
  • 保存返回的 decision_id,以后回传业务结果时要用。

过期、冷却与重试

  • token 最长约 2 分钟,并受许可截止时间限制。过期后由服务端创建新的 intent,移除旧组件,用新 intent 重新渲染。
  • 组件提示需要等待(冷却)时,按提示等待;重置组件不能绕过冷却。
  • 网络失败、非 JSON 响应和非成功状态都不能放行业务。按错误码与排查区分「重新验证」和「原样重试」。

上线前检查

  • 正常验证后可以提交;缺失、伪造、过期和重复的 token 都被拒绝。
  • 其他会话、其他站点、错误 action 或 hostname 的 token 都被拒绝。
  • 同一次提交并发到达或网络中断后重试:验证只扣费一次,业务只执行一次。
  • 额度不足、服务故障和限流时,页面显示可以恢复的提示,且不会放行业务。
  • 仍然保留你的身份认证、CSRF 防护、输入校验和业务限流。

也可以先运行完整表单示例,它包含会话绑定和安全重试的完整写法。

处理组件回调

成功、错误、冷却和过期回调会在组件更新状态之后执行。你的回调同步抛出异常时,组件会把异常报告给浏览器控制台,但不会把它当作验证失败、不会清空仍然有效的 token,也不会触发另一个错误回调。组件不会等待回调返回的 Promise,异步错误请在你的代码里捕获。

成功回调只表示浏览器拿到了 token,提交和注册仍然要由服务端兑换后执行。回调出错不会延长 token 有效期,也不会自动重试业务提交。

在回调里调用 reset(id)remove(id) 会取消组件当前的任务。许可过期后由你的服务端决定是否创建新的 intent,组件不会自己续期。

同页多个组件

不同的表单可以各自挂载组件;调用 getResponseresetremove 时传入对应的组件 ID。一张表单只能挂载一个组件:重复挂载会在修改第二个挂载点之前报错,避免共用的 moe-captcha-response 字段互相覆盖。先调用 remove(id),再在同一张表单的新位置创建组件。不要手动复制、移动或删除组件管理的隐藏字段。

可选:短时挑战豁免

部署开启这项功能后,你的服务端可以为两类访客申请有限次数的豁免:已通过你自己认证的会话(merchant_session),或者刚完成并成功兑换过一次挑战的访客(completed_challenge)。豁免只限定站点、action、hostname 和会话,免掉的是挑战这一步,不免最终兑换和计费,也不代表证明了访客是真人。

在服务端调用 POST /grants,使用站点 Secret 的 Bearer 请求头,请求体包含:

  • request_id:新的 UUID;保存原始请求,以便重试。
  • sourcemerchant_sessioncompleted_challenge
  • actionhostname:允许的业务场景。
  • session:你的服务端会话按站点计算的 HMAC,64 位小写十六进制;不要发送账号标识或会话 Cookie。
  • expires_at:Unix 秒,不超过当前时间后 5 分钟。
  • uses:正整数,不超过部署配置的上限,最多 4 次。
  • source_intent_id:仅 completed_challenge 需要;它必须属于同一业务场景、已经兑换过且仍在有效期内。通过豁免完成的验证不能再派生新的豁免。

响应包含 grant_idsourceexpires_atuses。在下一次 /intents 请求中加上 trusted_grant_id 和同一个 session。浏览器仍然只拿到普通的 sitekey 和 intent_id;Secret 和会话关联只留在服务端。

组件使用 mode: 'passive' 可以自动开始。豁免有效时,组件无需计算就能拿到 token;如果风险较高仍需要挑战,会走正常的挑战流程。无论哪种情况,都要先通过 /siteverify 兑换 token,并确认 intent 属于当前会话,再执行业务。

撤销豁免:调用 POST /grants/revoke,请求体为 { "grant_id": "…" },使用站点 Secret。会话失效时请撤销相关豁免。撤销后,尚未兑换的豁免 token 都会失效;已经完成的幂等兑换结果仍然可以取回。用同一请求重试创建不会延长有效期或增加次数;重置组件也不会恢复已用掉的次数。

创建请求内容冲突返回 409 grant-conflict;豁免无效、过期、已撤销或次数用完返回 400 invalid-or-expired-grant;服务故障返回 503。豁免无法使用时,你的服务端可以不带豁免创建普通 intent。不要为了实现这个回退而把站点 Secret 暴露给浏览器。