GenerateSaaS

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 }.

KeyTypeDescription
enabledbooleanMaster flag. When false, verification is a no-op and widgets are hidden.
provider"turnstile"Cloudflare Turnstile is the only supported provider.
siteKeystringPublic 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:

packages/config/src/index.ts
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

KeyWhere it livesRole
Site keyconfig.captcha.siteKey, overridden by NEXT_PUBLIC_TURNSTILE_SITE_KEY when that is setPublic. Renders the widget.
Secret keyTURNSTILE_SECRET_KEYServer-side. Verifies the token.
PairSite keySecret key
Cloudflare test - always passes, any domain1x00000000000000000000AA1x0000000000000000000000000000000AA
ProductionYour widget's site keyYour 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

SurfaceMechanismToken transport
Sign-up, sign-in, password reset, magic linkBetter Auth captcha plugin (packages/auth/src/config.ts)x-captcha-response header
Contact formManual verifyCaptcha (packages/api/src/routes/internal/contact.ts)captchaToken in JSON body
WaitlistThe 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.

On this page