CAPTCHA
Protect auth, password-reset, contact, and waitlist forms from bots with Cloudflare Turnstile, gated by config.captcha.
config.captcha (packages/config/src/index.ts) gates CAPTCHA protection, and ships disabled ({ enabled: false }). Enabled, the frontend renders a Cloudflare Turnstile widget on public forms and the backend verifies every token; disabled, verifyCaptcha() returns true, the Better Auth plugin is never registered, and no widget renders.
Configuration
config.captcha is a discriminated union: { enabled: false } or { enabled: true; provider; siteKey }.
| Key | Type | Description |
|---|---|---|
enabled | boolean | Master flag. When false, verification is a no-op and widgets are hidden. |
provider | "turnstile" | Cloudflare Turnstile is the only supported provider. |
siteKey | string | Public Turnstile site key, and the fallback for every deployment that sets no override. |
Your project ships captcha: { enabled: false } and no key of either half. Turning it on means writing both together:
captcha: {
enabled: true,
provider: "turnstile",
// Your widget's site key. Locked to its domains, so it works only where you registered it.
siteKey: "0x4AAAAAAA..."
}The two keys are a pair
| Key | Where it lives | Role |
|---|---|---|
| Site key | config.captcha.siteKey, overridden by NEXT_PUBLIC_TURNSTILE_SITE_KEY when that is set | Public. Renders the widget. |
| Secret key | TURNSTILE_SECRET_KEY | Server-side. Verifies the token. |
| Pair | Site key | Secret key |
|---|---|---|
| Cloudflare test - always passes, any domain | 1x00000000000000000000AA | 1x0000000000000000000000000000000AA |
| Production | Your widget's site key | Your widget's secret |
A site key only validates against its own secret. Mixing halves - a real site key with the test secret, or the reverse - makes the auth endpoints answer 403 to a widget the user just solved, with nothing in the server log to explain it. Swap both or neither.
A real site key is locked to its domains, so one committed key cannot serve several deployments. Commit the key your main domain uses and point every other deployment at its own widget through NEXT_PUBLIC_TURNSTILE_SITE_KEY. That override is inlined at build time - pass it as a Docker --build-arg or a platform build variable, because a value added only to a running container is ignored.
Cloudflare's test pair is for local work, never a deployed site: the test site key renders a widget stamped "For testing only. If seen, report to site owner" across your form.
The Better Auth captcha plugin registers only when both config.captcha.enabled is true and TURNSTILE_SECRET_KEY is set. Flag on with the secret missing leaves auth endpoints unprotected and no widget rendered.
Protected surfaces
| Surface | Mechanism | Token transport |
|---|---|---|
| Sign-up, sign-in, password reset, magic link | Better Auth captcha plugin (packages/auth/src/config.ts) | x-captcha-response header |
| Contact form | Manual verifyCaptcha (packages/api/src/routes/internal/contact.ts) | captchaToken in JSON body |
| Waitlist | The same plugin on /sign-in/magic-link (the waitlist form submits through magic-link sign-in) | x-captcha-response header |
The plugin guards exactly /sign-up/email, /sign-in/email, /request-password-reset, and /sign-in/magic-link. The contact handler rejects a missing token with 400 captcha_required and a bad token with 400 captcha_failed; a honeypot field short-circuits before verification.
Server verification
verifyCaptcha(token, ip?) (packages/auth/src/captcha/index.ts) is the single entry point for anything the plugin does not cover. It returns true immediately when CAPTCHA is disabled, otherwise POSTs the token and client IP to Cloudflare's siteverify endpoint.
import { verifyCaptcha } from "@repo/auth/captcha";
const ok = await verifyCaptcha(token, ip);Frontend widget
Forms render <Turnstile> from next-turnstile with siteKey={captchaSiteKey} - the resolved key from apps/web/lib/captcha.ts, which prefers NEXT_PUBLIC_TURNSTILE_SITE_KEY and falls back to config. The useCaptcha hook (apps/web/hooks/use-captcha.ts) holds the token plus a captchaKey you pass as the widget key, so resetCaptcha() bumps the key and remounts the widget after a failed submit.
Switching it off
Set config.captcha = { enabled: false }. Verification becomes a no-op, the plugin is skipped, and every widget unmounts - no env var changes required.
Frequently asked questions
Auth endpoints aren't being challenged - why?
The plugin needs both config.captcha.enabled and TURNSTILE_SECRET_KEY. With the secret unset it is never registered.
Sign-in returns 403 even though the widget solved - why?
The site key and secret are from different widgets, so the token the browser sent is one the server's secret cannot validate - the plugin rejects the request before the handler runs (POST /api/auth/sign-in/magic-link 403). Check that NEXT_PUBLIC_TURNSTILE_SITE_KEY (or config.captcha.siteKey) and TURNSTILE_SECRET_KEY are two halves of one pair, and restart the server after changing the secret - it is read at boot.
Is the site key secret?
No. It is public - it ships in the client bundle either way. Only TURNSTILE_SECRET_KEY is sensitive and server-side.
Can I use a provider other than Turnstile?
Not without code changes. Add the literal to CaptchaProvider in packages/config/src/types/services.ts and a case in verifyCaptcha - the exhaustiveCheck forces it.
Contact form returns captcha_required - what's wrong?
CAPTCHA is on but the request carried no captchaToken. Make sure the widget mounted and supplied a token before submit.