src/lib/turnstile.ts verifies the token
before the action runs. If env vars are unset, verification gracefully
returns { ok: true, skipped: true } so dev and unconfigured deployments
keep working.
Why Turnstile (vs reCAPTCHA)
- Privacy-friendly. No cookies, no Google tracking, GDPR-friendly out of the box.
- Free at any scale — no usage cap.
- Lower user friction — most challenges are invisible (managed mode).
- Cloudflare ecosystem — if you already proxy through Cloudflare, this is the obvious choice.
Prerequisites
- A Cloudflare account (free tier is fine).
- Your domain registered as a Turnstile site at dash.cloudflare.com → Turnstile → Add Site.
- The site key and the secret key from that site’s settings page.
Step-by-step
-
Create a Turnstile site in the Cloudflare dashboard. Add your
production domain plus
localhostfor local dev. Pick “Managed” mode for the best UX (Cloudflare decides when to challenge). -
Set both env vars in
.env.local: -
Confirm the feature flag in
src/config/site.ts(already on by default): -
Restart
pnpm dev. The widget renders on the signup, forgot-password, and newsletter forms. The server actions automatically callverifyTurnstile(token)and reject the request when the token is missing or invalid.
Verify it works
- Open
/registerin incognito — you should see the Turnstile widget (usually invisible, or a quick “verifying” flash). - Open devtools → Network. On submit, the form posts a
cf-turnstile-responsefield. The server action callshttps://challenges.cloudflare.com/turnstile/v0/siteverifyinternally; you’ll see no extra outbound request from the browser. - Try submitting with
NEXT_PUBLIC_TURNSTILE_SITE_KEYremoved — the widget disappears. Server-side,turnstileEnabled()returnsfalseand verification is skipped (returns{ ok: true, skipped: true }). - Tamper with the token via devtools → server action returns an error, form does not submit.
Common pitfalls
- Token can only be used once. If a server action retries internally with the same token (e.g. after a transient DB error), the second call will fail. Either reset the widget on retry or short-circuit at the action boundary.
- Token expires after ~5 minutes. Long-lived form pages (e.g. a multi-step
signup) need to refresh the widget before submit. Turnstile’s JS will
auto-refresh if you call
turnstile.renderwithexpired-callback. - Don’t trust the client widget alone. A green widget without server
verification is theatre.
verifyTurnstile()is what actually protects you; never bypass it in your own server actions. - Missing token in production. Common cause:
TURNSTILE_SECRET_KEYset, butNEXT_PUBLIC_TURNSTILE_SITE_KEYnot deployed (env var not added to the hosting platform). Result: the widget doesn’t render → no token → server rejects every signup. Always deploy both vars together. - Localhost not allow-listed. Add
localhost(and127.0.0.1if you use it) to your Turnstile site’s domain list, or local signups will silently fail.
How it’s wired
verifyTurnstile(input.cfTurnstileToken) first thing and
short-circuit on !ok. Adding it to a new form means: render the widget on
the client, pass the token through the action’s input schema, and call
verifyTurnstile in the action body.
Disable Turnstile for a deployment
Either:- Set
siteConfig.features.enableTurnstile = false(compile-time off), or - Leave both env vars unset (runtime skip).
verifyTurnstile return { ok: true, skipped: true } so
the rest of the action proceeds normally.
Official docs
- Turnstile docs: developers.cloudflare.com/turnstile
- Server-side verification: developers.cloudflare.com/turnstile/get-started/server-side-validation
- Test keys: developers.cloudflare.com/turnstile/troubleshooting/testing