docs

CAPTCHA (captcha)

Stop bots on the routes they love: sign up, sign in, password reset and magic links (or any routes you list). The frontend renders a Cloudflare Turnstile, hCaptcha or Google reCAPTCHA v3 widget and sends its token with the request; the API checks it with the provider before the route runs, so a refused request never looks up an account, sends an email or hashes a password. reCAPTCHA v3 gets a minimum score and an action check. When the provider does not answer, requests are refused by default. Without a secret key, development and test skip the check so local work needs no keys. No dependencies.

Terminal
npx calm add captcha

Adds#

Filesapp/blocks/captcha/index.js
EnvironmentTURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY (or HCAPTCHA_SITE_KEY / HCAPTCHA_SECRET_KEY, RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY)
RoutesGET /api/captcha
ProtectsPOST /api/auth/register, POST /api/auth/login, POST /api/auth/password/forgot, POST /api/auth/magic-link (when the magic-link block is installed)

Setup#

  1. Create a site at your provider and copy its two keys:
    • Cloudflare Turnstile (default): Cloudflare dashboard, Turnstile, "Add widget".
    • hCaptcha: hCaptcha dashboard, add a site; set provider: 'hcaptcha'.
    • reCAPTCHA v3: reCAPTCHA admin console, type "Score based (v3)"; set provider: 'recaptcha'.
  2. Put them in .env:
Terminal
TURNSTILE_SITE_KEY=0x4AAAA...
TURNSTILE_SECRET_KEY=0x4AAAA...
  1. Run npx calm doctor. In production a missing secret key is an error: every protected route answers 503 until it is set.

For staging, Cloudflare documents dummy Turnstile keys that always pass or always fail ("Testing" in the Turnstile docs); they work with this block unchanged.

The frontend#

Ask the API what to render:

HTTP
GET /api/captcha
JSON
200 { "data": { "provider": "turnstile", "siteKey": "0x4AAAA...", "header": "x-captcha-token", "field": "captchaToken", "routes": ["POST /auth/register", "POST /auth/login", "POST /auth/password/forgot", "POST /auth/magic-link"] } }

With reCAPTCHA and an action option, the answer also has action (one for every route) or actions (per route).

Send the widget's token with the protected request, in the x-captcha-token header (preferred) or a captchaToken body field:

Text
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="login">
  <input name="email" type="email" /> <input name="password" type="password" />
  <div class="cf-turnstile" data-sitekey="0x4AAAA..." data-action="login"></div>
  <button>Sign in</button>
</form>
<script>
  document.querySelector('#login').addEventListener('submit', async (e) => {
    e.preventDefault();
    const form = new FormData(e.target);
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'x-captcha-token': form.get('cf-turnstile-response') },
      body: JSON.stringify({ email: form.get('email'), password: form.get('password') }),
    });
    if (!res.ok) turnstile.reset(); // tokens work once: get a fresh one for the next try
  });
</script>

reCAPTCHA v3 has no widget; ask for a token with the action of the page:

JavaScript
const token = await grecaptcha.execute(siteKey, { action: 'login' });
await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json', 'x-captcha-token': token }, body: JSON.stringify({ email, password }) });

Errors#

StatusCodeWhen
400CAPTCHA_REQUIREDNo token in the header or the body.
400CAPTCHA_INVALIDThe provider refused the token (wrong, expired, already used), the score is below minScore, the action or the hostname does not match.
503CAPTCHA_UNAVAILABLEThe provider did not answer (and failOpen is off), or there is no secret key outside development and test.

Options#

JavaScript
export default defineConfig({
  blocks: {
    captcha: {
      provider: 'recaptcha',
      routes: ['POST /auth/register', 'POST /auth/login', 'POST /posts/:id/comments'],
      minScore: 0.5,
      action: { 'POST /auth/login': 'login', 'POST /auth/register': 'signup', 'POST /posts/:id/comments': 'comment' },
    },
  },
});
OptionDefaultWhat it does
provider'turnstile''turnstile', 'hcaptcha' or 'recaptcha' (v3).
routesthe four auth routes aboveRoutes that need a token: 'METHOD /path' as declared, without the API prefix ('POST /api/posts' is accepted too). The list replaces the defaults. Doctor warns about entries that match no route.
header'x-captcha-token'Header the token is read from.
field'captchaToken'Body field read when the header is missing. It is removed before the route validates its input.
minScore0.5reCAPTCHA v3: lowest score accepted (0.0 is a bot, 1.0 a human).
actionnullExpected action: one string, or a map from route to action. reCAPTCHA v3 always checks it when set; Turnstile when the widget declared one (data-action).
hostnamesnullHostnames the token must have been solved on, e.g. ['app.example.com'].
failOpenfalseLet requests through when the provider does not answer. A refused token is still refused.
timeoutMs5000Timeout of the siteverify request.
bypassnullWithout a secret key, development and test skip the check (one warning in the log). false refuses instead. Production never skips.
verifyUrlthe provider'sSiteverify endpoint override (tests, an egress proxy).

Security#

  • The token is checked server side with the secret key, the client IP (remoteip) and, for hCaptcha, the site key. Only the site key is ever returned to clients.
  • Tokens are single use at the provider, so a solved token cannot be replayed.
  • The check is a guard (calm.guard, order -50), so it runs before other guards, the policy, input validation and the handler, after the auth rate limit.
  • Fails closed: an attacker who can slow the provider down does not switch the protection off. failOpen: true trades that for availability.
  • reCAPTCHA v3: the action check stops a token earned on a harmless page from being spent on login.
  • Behind a proxy, set TRUST_PROXY so the client IP sent to the provider is the real one.
  • A CAPTCHA slows down bots, it does not stop a determined attacker. Keep the auth rate limit and account lockout on.

Events#

EventPayload
security.captcha_failed{ path, ip, reason, score? }; reason is missing, too_long, low_score, wrong_action, wrong_hostname or rejected:<provider error codes>
security.captcha_unavailable{ path, ip, failOpen }
<!-- block-guide:start (generated from block.json by scripts/sync-block-guides.mjs) -->

Official documentation#

Read these before changing the block; they are also in app/blocks/captcha/AGENTS.md and in the devtools Blocks panel.

For AI agents#

Coding agents read these notes in app/blocks/captcha/AGENTS.md:

Read app/blocks/captcha/index.js first: one guard (calm.guard, order -50) that checks the listed routes, the siteverify call and a public GET /api/captcha route for the frontend. Configuration lives in calm.config.js under blocks.captcha (provider, routes, header, field, minScore, action, hostnames, failOpen, timeoutMs, bypass, verifyUrl). Keys come from .env: TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY, HCAPTCHA_SITE_KEY/HCAPTCHA_SECRET_KEY or RECAPTCHA_SITE_KEY/RECAPTCHA_SECRET_KEY. Invariants that must not be broken:

  • Tokens are verified server side with the secret key; never trust a client flag such as { captchaOk: true }, and never send the secret key to the frontend (only the site key is public).
  • Keep failing closed by default (503 CAPTCHA_UNAVAILABLE) when the provider does not answer; failOpen is an explicit opt-in.
  • Never bypass in production: the check is skipped only when no secret key is set and the mode is development or test.
  • reCAPTCHA v3: keep the minScore and action checks; map each protected route to the action its page declares.
  • The body field (captchaToken) is deleted by the guard before input validation; keep that, or strict input validation answers 422. Common tasks: protect another route by adding "POST /posts" (method and path without the /api prefix) to blocks.captcha.routes (the list replaces the defaults, so keep the auth routes you still want); switch provider by setting provider and the matching env vars. Error codes for frontends: 400 CAPTCHA_REQUIRED (no token), 400 CAPTCHA_INVALID (rejected, low score, wrong action or hostname), 503 CAPTCHA_UNAVAILABLE. Tokens are single use: render a fresh widget token after every failed attempt. Pitfalls: routes are matched exactly as declared ("POST /posts/:id/comments"); calm doctor warns about entries that match no route. Tests should point verifyUrl at a fake siteverify server and set the secret env var at runtime.
<!-- block-guide:end -->

Last updated