GenerateSaaS

CAPTCHA

Protect auth, password-reset, contact, and waitlist forms from bots with Cloudflare Turnstile, gated by config.captcha.

CAPTCHA protection is gated by config.captcha in packages/config/src/index.ts. When enabled (provider turnstile) the frontend renders a Cloudflare Turnstile widget on public forms and the backend verifies every token; when off, verifyCaptcha() returns true, the better-auth plugin is not registered, and no widget renders.

What you get

  • The better-auth captcha plugin guards four auth endpoints automatically.
  • Manual server verification on the contact form.
  • A shared frontend widget (next-turnstile) driven by the useCaptcha hook.
  • One swappable provider: Cloudflare Turnstile.

The site key is public and lives in config.captcha.siteKey. Only the secret key is an environment variable - see below.

Configuration

config.captcha is a discriminated union: { enabled: false } or { enabled: true; provider; siteKey }.

KeyTypeDefaultDescription
enabledbooleantrueMaster flag. When false, verification is a no-op and widgets are hidden.
provider"turnstile""turnstile"CAPTCHA backend. Cloudflare Turnstile only.
siteKeystringdemo keyPublic Turnstile site key from the Cloudflare dashboard.
packages/config/src/index.ts
captcha: {
  enabled: true,
  provider: "turnstile",
  siteKey: "0x4AAAAAA..."
}

The secret key lives in an environment variable, not config.

VarFeatureWhen needed
TURNSTILE_SECRET_KEYCAPTCHAconfig.captcha enabled

See Environment Variables.

The better-auth captcha plugin only registers when both config.captcha.enabled is true and TURNSTILE_SECRET_KEY is set. With the flag on but the secret missing, auth endpoints are left unprotected.

Server verification

verifyCaptcha(token, ip?) (packages/auth/src/captcha/index.ts) is the single entry point. It returns true immediately when config.captcha.enabled is false, otherwise dispatches to verifyTurnstile, which POSTs the token (and client IP) to Cloudflare's siteverify endpoint and returns its success flag.

import { verifyCaptcha } from "@repo/auth/captcha";

const ok = await verifyCaptcha(token, ip);

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
WaitlistBetter Auth captcha plugin on /sign-in/magic-link (the waitlist form submits through magic-link sign-in)x-captcha-response header

The auth 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).

Frontend widget

Forms render <Turnstile> from next-turnstile with siteKey={config.captcha.siteKey} and drive state through the useCaptcha hook (apps/web-next/hooks/use-captcha.ts): it holds the token and a captchaKey you pass as the widget key, so resetCaptcha() bumps the key to remount the widget after a failed submit.

Switching it off

Set config.captcha = { enabled: false }. Verification becomes a no-op, the auth plugin is skipped, and every widget unmounts - no env var changes required. The CLI ships buyer projects with captcha disabled by default.

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 the plugin is never registered.

Where does the site key go - is it secret? The site key is public and lives in config.captcha.siteKey. 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/index.ts and a case in verifyCaptcha - the exhaustiveCheck enforces it.

Contact form returns captcha_required - what's wrong? Captcha is enabled but the request arrived without a captchaToken. Ensure the widget mounted and supplied a token before submit.

On this page