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 (
<CaptchaTurnstile>) driven by theuseCaptchacomposable. - 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 }.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master flag. When false, verification is a no-op and widgets are hidden. |
provider | "turnstile" | "turnstile" | CAPTCHA backend. Cloudflare Turnstile only. |
siteKey | string | demo key | Public Turnstile site key from the Cloudflare dashboard. |
captcha: {
enabled: true,
provider: "turnstile",
siteKey: "0x4AAAAAA..."
}The secret key lives in an environment variable, not config.
| Var | Feature | When needed |
|---|---|---|
TURNSTILE_SECRET_KEY | CAPTCHA | config.captcha enabled |
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
| 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 | Better 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 <CaptchaTurnstile> (apps/web-nuxt/app/components/shared/CaptchaTurnstile.vue), which lazy-loads Cloudflare's api.js and renders with sitekey: config.captcha.siteKey. The useCaptcha composable (apps/web-nuxt/app/composables/useCaptcha.ts) holds the token and a template ref to the widget, so resetCaptcha() re-runs the challenge 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.