接入细节与进阶
快速接入给出了可以直接运行的最小示例。这一页说明正式上线前需要处理的细节:数据存在哪里、怎样安全地重试、组件回调、同页多个组件,以及可选的挑战豁免。示例中的服务地址是托管服务 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.moe和connect-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,组件不会自己续期。
同页多个组件
不同的表单可以各自挂载组件;调用 getResponse、reset、remove 时传入对应的组件 ID。一张表单只能挂载一个组件:重复挂载会在修改第二个挂载点之前报错,避免共用的 moe-captcha-response 字段互相覆盖。先调用 remove(id),再在同一张表单的新位置创建组件。不要手动复制、移动或删除组件管理的隐藏字段。
可选:短时挑战豁免
部署开启这项功能后,你的服务端可以为两类访客申请有限次数的豁免:已通过你自己认证的会话(merchant_session),或者刚完成并成功兑换过一次挑战的访客(completed_challenge)。豁免只限定站点、action、hostname 和会话,免掉的是挑战这一步,不免最终兑换和计费,也不代表证明了访客是真人。
在服务端调用 POST /grants,使用站点 Secret 的 Bearer 请求头,请求体包含:
request_id:新的 UUID;保存原始请求,以便重试。source:merchant_session或completed_challenge。action、hostname:允许的业务场景。session:你的服务端会话按站点计算的 HMAC,64 位小写十六进制;不要发送账号标识或会话 Cookie。expires_at:Unix 秒,不超过当前时间后 5 分钟。uses:正整数,不超过部署配置的上限,最多 4 次。source_intent_id:仅completed_challenge需要;它必须属于同一业务场景、已经兑换过且仍在有效期内。通过豁免完成的验证不能再派生新的豁免。
响应包含 grant_id、source、expires_at 和 uses。在下一次 /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 暴露给浏览器。