快速接入

用十几分钟,把验证码接进一个注册表单:服务端创建许可,页面放入组件,服务端校验结果,最后才执行业务。

一次验证怎么走

验证服务在 https://captcha.moe,组件脚本是 https://captcha.moe/widget.js。每一次受保护的操作(一次注册、一次登录)都会走下面四步。只想看代码,可以直接跳到服务端代码

  1. 你的服务端创建 intent渲染表单前调用 POST /intents,拿到 intent_id
  2. 访客的浏览器完成验证组件凭 intent_id 完成挑战,把 token 写进表单字段 moe-captcha-response
  3. 你的服务端兑换 token收到表单后调用 POST /siteverify,核对返回的结果
  4. 你的服务端执行业务兑换成功才注册、下单或发帖;每个 token 只能用一次
静态页面也需要服务端组件必须拿到服务端刚创建的 intent_id 才能工作,所以表单页面要由服务端渲染,或者由前端向你自己的后端请求一个 intent。纯静态 HTML 无法接入。

几个词

Site key mc_pub_…
公开的站点标识,写在页面里。
Secret key mc_sec_…
只放在服务端,放在 Authorization: Bearer 请求头里。泄露后在控制台更换,旧密钥立即失效。
intent(验证许可)
服务端为「这一次操作」申请的许可。5 分钟内有效,只能兑换一次。组件必须拿到 intent_id 才能开始验证。
action
业务动作名,例如 signuplogin,由你决定。创建和兑换时必须一致,这样登录页的 token 无法拿去注册。
hostname
表单所在的域名,不含协议和端口,例如 example.com。必须在站点的允许域名里。
nonce
创建 intent 时由服务端生成的随机值(16–128 位字母、数字、-_)。网络失败后重试同一次创建时复用它,不会产生第二个许可。
token
访客完成验证后,组件写进表单字段 moe-captcha-response 的字符串。约 2 分钟内有效。
idempotency_key
兑换时的幂等键,规则和 nonce 相同。同一次提交重试时复用它,不会重复扣费;不同的提交用不同的键。

1创建站点

在控制台添加站点,填写表单所在的域名,拿到 Site key 和 Secret key。Secret key 只显示一次,请直接保存到服务端的环境变量或密钥管理里。

先在本机试,就在允许的域名里加上 localhost去控制台添加站点

2复制服务端代码

下面是一个完整、可以直接运行的注册表单服务,只用语言自带的库。代码里的 ①–④ 对应上面的四个步骤:打开页面时创建 intent,提交时兑换 token,成功后才执行业务。

// server.mjs — Node.js 22 或更高,不需要安装依赖
// 运行:CAPTCHA_SECRET=mc_sec_你的密钥 node server.mjs,然后打开 http://localhost:3000
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';

const API = 'https://captcha.moe';
const SECRET = process.env.CAPTCHA_SECRET;
const HOSTNAME = 'localhost'; // 必须在站点的「允许的域名」里
if (!SECRET) throw new Error('请先设置环境变量 CAPTCHA_SECRET');

// 演示用的内存存储:一次表单 → 这次验证的全部绑定信息。正式业务请放进数据库或会话。
const pending = new Map();

async function captcha(path, body) {
  const res = await fetch(API + path, {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${SECRET}` },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(5000),
  });
  return { status: res.status, data: await res.json().catch(() => ({})) };
}

const form = (operation, sitekey, intent) => `<!doctype html><meta charset="utf-8">
<script src="${API}/widget.js" async defer></script>
<form method="post" action="/signup">
  <input type="hidden" name="operation" value="${operation}">
  <input name="email" type="email" required placeholder="you@example.com">
  <div class="moe-captcha" data-sitekey="${sitekey}" data-intent="${intent}" data-endpoint="${API}"></div>
  <button>注册</button>
</form>`;

function send(res, status, text, type = 'text/plain') {
  res.writeHead(status, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store' });
  res.end(text);
}

createServer(async (req, res) => {
  try {
    if (req.method === 'GET' && req.url === '/') {
      // ① 渲染表单之前,由服务端创建 intent,并保存这次验证的绑定信息
      const op = { id: randomUUID(), nonce: randomUUID(), key: randomUUID(), action: 'signup', hostname: HOSTNAME };
      const { status, data } = await captcha('/intents', { action: op.action, nonce: op.nonce, hostname: op.hostname });
      if (status !== 200) return send(res, 503, `暂时无法创建验证:${data.error ?? status}`);
      pending.set(op.id, { ...op, intent: data.intent_id });
      return send(res, 200, form(op.id, data.sitekey, data.intent_id), 'text/html');
    }
    if (req.method === 'POST' && req.url === '/signup') {
      let raw = '';
      for await (const chunk of req) raw += chunk;
      const fields = new URLSearchParams(raw);
      const op = pending.get(fields.get('operation') ?? '');
      const token = fields.get('moe-captcha-response'); // ② 组件写进表单的 token
      if (!op || !token) return send(res, 400, '请刷新页面,重新完成验证。');

      // ③ 服务端兑换 token。除了 token,其余参数都来自服务端保存的记录
      const { status, data } = await captcha('/siteverify', {
        response: token, intent_id: op.intent, action: op.action, hostname: op.hostname, idempotency_key: op.key,
      });
      if (status === 429 || status >= 500) {
        // 暂时不可用:保留记录,访客重新提交会用同一个幂等键重试,不会重复扣费
        return send(res, 503, '验证服务暂时不可用,请稍后重新提交。');
      }
      pending.delete(op.id);
      if (status !== 200 || data.success !== true || data.intent_id !== op.intent ||
          data.action !== op.action || data.hostname !== op.hostname) {
        // token 过期、已用或不匹配:重新验证(刷新页面会创建新的 intent)
        return send(res, 400, `验证未通过(${data.error ?? '结果不匹配'}),请刷新页面重新验证。`);
      }
      // ④ 验证通过后才执行业务。这里只回显邮箱
      return send(res, 200, `注册成功:${fields.get('email')}`);
    }
    send(res, 404, '');
  } catch {
    send(res, 503, '验证服务暂时不可用,请稍后重新提交。'); // 网络错误或超时:可以原样重试
  }
}).listen(3000, () => console.log('打开 http://localhost:3000'));

其他语言照同样的两次 HTTP 调用即可,参见服务端 API。需要持久会话、并发重试和业务幂等的完整写法,见完整表单示例接入细节与进阶

3页面里的组件

上面的服务端代码已经生成了这段 HTML。接入你自己的页面时,把组件放进 form 里,由服务端模板填入 sitekey 和 intent_id:

HTML
<script src="https://captcha.moe/widget.js" async defer></script>

<form method="post" action="/signup">
  <input name="email" type="email" required>
  <!-- sitekey 和 intent_id 由你的服务端在渲染这个页面时填入 -->
  <div class="moe-captcha"
       data-sitekey="{{ sitekey }}"
       data-intent="{{ intent_id }}"
       data-endpoint="https://captcha.moe"></div>
  <button type="submit">注册</button>
</form>

访客完成验证后,组件在表单里写入隐藏字段 moe-captcha-response。组件显示「已验证」只代表浏览器拿到了 token,是否放行由你的服务端兑换后决定。模式、外观和回调见组件配置

4运行并检查

export CAPTCHA_SECRET=mc_sec_你的密钥
node server.mjs
# 打开 http://localhost:3000

打开页面后逐项确认:

  • 正常完成验证后,提交成功。
  • 不完成验证直接提交、或把同一个 token 再提交一次,都会被拒绝。
  • 在控制台的站点数据里能看到「校验成功」次数增加。

用 curl 试接口

不写代码,也可以先看看两次调用分别返回什么:

# ① 创建 intent(在你的服务端执行,Secret 不能出现在浏览器里)
curl -sS https://captcha.moe/intents \
  -H "Authorization: Bearer $CAPTCHA_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"action":"signup","nonce":"'"$(uuidgen)"'","hostname":"localhost"}'

# 返回:
# {"intent_id":"8c1f…","sitekey":"mc_pub_…","action":"signup","hostname":"localhost","expires_at":1789400000}

失败时怎么办

错误响应都是 {"error":"错误码"}。先看是哪一类,再决定是让访客重新验证,还是原样重试:

情况你会看到该怎么做
token 过期、已用,或和这次提交对不上400 / 409:invalid-or-expired-permitinvalid-input-responsealready-redeemed让访客重新验证:创建新的 intent,重新渲染组件
密钥或域名配置错误401 invalid-input-secret、403 origin-not-allowed修正配置,重试不会成功
本月额度用完且余额不足402 insufficient-balance充值后用同一个请求重试
服务暂时不可用、限流、网络超时503 unavailable、429 rate-limited、超时保留这次提交,用相同的参数和幂等键重试;得到结果前不要执行业务

所有错误码见错误码与排查

SDK 与框架

SDK 会替你处理请求、超时和结果核对。包名已经确定,发布到各平台前,请从仓库对应目录安装,或者直接使用上面的 HTTP 写法(不需要 SDK)。Node.js 服务端 SDK 需要 Node.js 22 或更高;前端框架包需要 Node.js 22.22+ 或 24.15+。

用途包名安装命令源码目录
Node.js 服务端@captcha-moe/servernpm install @captcha-moe/server即将发布sdks/node
Python 服务端captchamoe-serverpip install captchamoe-server即将发布sdks/python
PHP 服务端captchamoe/servercomposer require captchamoe/server即将发布sdks/php
Go 服务端captcha.moe/sdkgo get captcha.moe/sdk即将发布sdks/go
Rust 服务端captchamoe-servercargo add captchamoe-server即将发布sdks/rust
前端框架(React、Vue、Svelte 等)@captcha-moe/frontendnpm install @captcha-moe/frontend即将发布integrations/frontend
Djangocaptchamoe-djangopip install captchamoe-django即将发布integrations/django
Laravelcaptchamoe/laravelcomposer require captchamoe/laravel即将发布integrations/laravel
Railscaptchamoe-railsgem 'captchamoe-rails'即将发布integrations/rails
WordPressCaptchaMoe 插件上传插件 zip 并启用即将发布integrations/wordpress

请求失败时,SDK 抛出的错误会带上接口返回的错误码和出错字段,可以直接对照错误码处理:

SDK错误码出错字段
Node.jserror.codeerror.field
Pythonerror.codeerror.field
Goerr.Codeerr.Field
PHP$e->apiCode$e->field
RustError::Api { status, code, field },或 code() / status()field

本地开发

本地开发直接使用线上组件 https://captcha.moe/widget.js,不需要自己运行验证服务:

  • 在站点的允许域名里加入 localhost,创建 intent 时 hostname 填 localhost
  • http://localhost:端口 打开页面。如果用 127.0.0.1 打开,要把 127.0.0.1 也加入允许域名,并用它作为 hostname。
  • 测试站点和正式站点分开建,避免测试流量混进正式数据。

自己部署验证服务时,把文中的 https://captcha.moe 换成你的服务地址。