Cloudflare Turnstile
Invisible CAPTCHA on sign-up, sign-in, and password reset. Zero cookies, unlimited free.
Cloudflare Turnstile runs invisibly on auth pages. Unlimited free usage, no cookies, GDPR-safe.
Setup
- Cloudflare Dashboard → Turnstile → Add Site
- Hostnames: your prod domain +
localhostfor dev - Widget mode: Invisible or Managed
- Copy the Site key (public) and Secret key (server):
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AA...
TURNSTILE_SECRET_KEY=0x4AA...Without these, Turnstile is not rendered and Better Auth skips the captcha check.
Where it's used
Shipped on the three authentication pages:
/sign-up/sign-in/forgot-password
Component: @marsidev/react-turnstile with size="invisible".
Token flow:
- Turnstile widget runs on mount, produces a token
- Client sends the token via the
x-captcha-responseheader on the Better Auth request - Better Auth's captcha plugin verifies the token server-side using
TURNSTILE_SECRET_KEY
Configuration
The Better Auth captcha plugin (lib/auth.ts) integrates Turnstile. If either env var is missing, the plugin is not enabled and auth flows proceed without challenge.
Dev tokens
Cloudflare provides always-pass test keys for local dev:
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AAUse these to iterate on sign-up UX without waiting on Cloudflare.
Not used on non-auth forms
Turnstile is not currently wired into:
/submit/sponsor/newsletter- Comment posting
Those routes rely on rate limiting + auth gates (most require a signed-in user). If you want Turnstile there too, add the <Turnstile> component and verify the response header in the route.
Switching to reCAPTCHA / hCaptcha
Swap the client library (@marsidev/react-turnstile → reCAPTCHA component) and change the server-side verification URL in Better Auth's captcha plugin config. The x-captcha-response header pattern works across providers.
Why Turnstile:
- Unlimited free (reCAPTCHA caps at 1M/month)
- No Google tracking
- Invisible mode (no user friction)
Common issues
"Invalid sitekey" — Wrong key or hostname mismatch. Check Dashboard → site config.
Server rejects with "invalid-input-response" — Token expired (5 min validity) or already used (one-shot tokens).
Token not being sent — The component renders but onSuccess isn't wired to a state/ref. Check app/sign-up/page.tsx for the pattern.