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.
npx calm add captchaAdds#
| Files | app/blocks/captcha/index.js |
| Environment | TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY (or HCAPTCHA_SITE_KEY / HCAPTCHA_SECRET_KEY, RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY) |
| Routes | GET /api/captcha |
| Protects | POST /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#
- 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'.
- Put them in
.env:
TURNSTILE_SITE_KEY=0x4AAAA...
TURNSTILE_SECRET_KEY=0x4AAAA...- Run
npx calm doctor. In production a missing secret key is an error: every protected route answers503until 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:
GET /api/captcha200 { "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:
<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:
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#
| Status | Code | When |
|---|---|---|
| 400 | CAPTCHA_REQUIRED | No token in the header or the body. |
| 400 | CAPTCHA_INVALID | The provider refused the token (wrong, expired, already used), the score is below minScore, the action or the hostname does not match. |
| 503 | CAPTCHA_UNAVAILABLE | The provider did not answer (and failOpen is off), or there is no secret key outside development and test. |
Options#
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' },
},
},
});| Option | Default | What it does |
|---|---|---|
provider | 'turnstile' | 'turnstile', 'hcaptcha' or 'recaptcha' (v3). |
routes | the four auth routes above | Routes 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. |
minScore | 0.5 | reCAPTCHA v3: lowest score accepted (0.0 is a bot, 1.0 a human). |
action | null | Expected 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). |
hostnames | null | Hostnames the token must have been solved on, e.g. ['app.example.com']. |
failOpen | false | Let requests through when the provider does not answer. A refused token is still refused. |
timeoutMs | 5000 | Timeout of the siteverify request. |
bypass | null | Without a secret key, development and test skip the check (one warning in the log). false refuses instead. Production never skips. |
verifyUrl | the provider's | Siteverify 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: truetrades 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_PROXYso 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#
| Event | Payload |
|---|---|
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 } |
Official documentation#
Read these before changing the block; they are also in app/blocks/captcha/AGENTS.md and in the devtools Blocks panel.
- Cloudflare Turnstile: Server-side validation
- Cloudflare Turnstile: Testing (dummy keys)
- hCaptcha: Developer guide (verify the user response server side)
- Google reCAPTCHA v3
- Google reCAPTCHA: Verifying the user response
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.