Cloudflare Turnstile
Next Starter uses Cloudflare Turnstile (MANAGED mode) to block bots on the sign-in, register, forgot-password, and contact forms, with server-side token checks and almost no friction for real users.
What Turnstile is
Turnstile is Cloudflare's free CAPTCHA — a check that tells real people apart from bots before a form is submitted. It replaces the "click all the traffic lights" puzzle. For most visitors it shows nothing at all; it watches how the page is used and decides in the background.
Here is the idea in plain terms:
- The form shows a small Turnstile widget (often invisible).
- When Turnstile is happy the visitor is human, it hands the browser a one-time token — a short, signed string that proves the check passed.
- The form sends that token to your server along with the rest of the data.
- Your server asks Cloudflare "is this token real and unused?" before doing anything. No valid token, no submission.
This stops bots from spamming your sign-up, password-reset, and contact forms, because a bot can't produce a valid token.
Next Starter runs Turnstile in MANAGED mode. This means Cloudflare decides when to show a challenge — invisible for most people, a quick interactive step only when something looks suspicious. Turnstile returns a plain pass or fail, not a score. There is no 0.0–1.0 number to compare, no threshold to tune, and no way to skip the check.
How it protects each form
The token is always checked on the server. There are two paths, depending on the form.
- Auth forms (sign-in, register, forgot-password) send the token to Better Auth in the
x-captcha-responserequest header. Better Auth'scaptchaplugin checks it before the auth action runs. - The contact form adds the token to
FormDataasturnstile_token. The server actionsubmitContactFormcallsvalidateTurnstilebefore it sends any email.
The client uses the @marsidev/react-turnstile package. Next Starter wraps it in one shared file, components/captcha-widget.tsx, which exports a CaptchaWidget component and a useTurnstile hook. Every protected form uses these instead of the raw widget.
Environment Variables
| Variable | Scope | Purpose |
|---|---|---|
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Client | Public site key passed to the <Turnstile> widget |
TURNSTILE_SECRET_KEY | Server | Secret key used to verify tokens on the server |
Both are required. They are validated at startup with Zod (z.string().min(1) in lib/validations/env.ts), so a missing key fails the build or boot right away instead of quietly turning protection off. See Environment Variables.
To create the keys: open the Cloudflare dashboard, go to Turnstile, add a widget, and choose MANAGED mode. Add your production domain and localhost under the widget's Hostnames so the same keys work in development.
Server-side verification
validateTurnstile (app/actions/turnstile.ts) is the function that confirms a token with Cloudflare. It POSTs the token to Cloudflare's siteverify endpoint and returns true only when result.success === true. There is no score and no threshold — just pass or fail.
If the network call or the response parsing fails, it returns false (it fails closed, so a glitch can never let a bot through). It logs a turnstile_verification_error event using after() from next/server, which runs the log after the response is sent so it stays off the user's critical path.
export async function validateTurnstile(token: string): Promise<boolean> {
// POST { secret, response: token } to challenges.cloudflare.com/turnstile/v0/siteverify
// return result.success === true; on error, log and return false
}Better Auth integration
Auth forms don't call validateTurnstile themselves. The client puts the token in the x-captcha-response header, and the captcha plugin in lib/auth.ts checks it on the server:
captcha({
provider: "cloudflare-turnstile",
secretKey: env.TURNSTILE_SECRET_KEY,
}),If the check fails, Better Auth rejects the request before the sign-in, register, or password-reset action runs. On the client, attach the token through fetchOptions.headers — see app/auth/sign-in/sign-in-form.tsx for the reference.
Client-side widget
All the widget logic lives in one file, components/captcha-widget.tsx. You almost never touch the raw @marsidev/react-turnstile <Turnstile> component — you use the two exports from this file instead:
useTurnstile()— a hook that owns the token and the widget ref. It returns{ ref, token, setToken, reset }.CaptchaWidget— the widget itself. You pass itinstanceRef(the ref from the hook) andonToken(use the hook'ssetToken).
In a form you call the hook, render the widget, and read token when you submit:
const { ref: turnstileRef, token, setToken, reset } = useTurnstile();
// ...in the form JSX:
<CaptchaWidget instanceRef={turnstileRef} onToken={setToken} />tokenholds the current token. You send it as thex-captcha-responseheader (auth forms) or add it toFormData(contact form).- The token is single-use. Call
reset()after every submit or server rejection to clear it and fetch a fresh challenge. - Inside
CaptchaWidget,appearance: "interaction-only"keeps the widget invisible unless interaction is needed, andsize: "flexible"makes it fit the form width. Change theseoptionsincomponents/captcha-widget.tsxif you need a different look.
Protecting a new form
The contact form (app/(site)/contact/contact-form.tsx plus app/actions/contact.ts) is the reference for any non-auth form. The token rides along in FormData and is checked inside the server action — no extra round-trip.
To protect a new form, in this order:
- Call
useTurnstile()and render theCaptchaWidgetin the form (as shown above). - Add the hook's
tokentoFormDataasturnstile_tokenbefore calling your server action. - In the server action, read
turnstile_tokenand stop early whenvalidateTurnstilereturns false:
const turnstileToken = formData.get("turnstile_token") as string | null;
if (!turnstileToken || !(await validateTurnstile(turnstileToken))) {
return { success: false, error: "Captcha verification failed" };
}For an auth-style flow instead, hand the token to Better Auth's captcha plugin via the x-captcha-response header rather than calling validateTurnstile yourself.
The only other knobs are the widget options (size, appearance) and the mode, which you set in the Cloudflare dashboard. There is no score threshold to adjust.
Content Security Policy
The widget loads a script and an iframe from https://challenges.cloudflare.com. The Content Security Policy (CSP) — the browser rule that lists which outside domains a page may load — already allows this in next.config.ts:
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://challenges.cloudflare.com`,
"connect-src 'self' https://*.r2.cloudflarestorage.com https://challenges.cloudflare.com",
"frame-src 'self' https://challenges.cloudflare.com",You don't need to change the CSP when you add Turnstile to a new form on the same site.
Testing in development
Because both keys are required, set NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY in .env or the app won't start. There is no "no secret, no check" bypass.
Create a MANAGED widget, add localhost to its Hostnames, and use those keys. Your production keys work locally as long as localhost is listed, or you can make a separate dev widget. In MANAGED mode the widget stays invisible for most interactions, so forms submit without a visible challenge while the server still verifies every token.
Security Headers & CSP
How Next Starter configures HTTP security headers and a Content Security Policy in next.config.ts, and how to add your own external domains.
Database Setup
How Next Starter connects PostgreSQL to Prisma 7 — local Docker, migrations, managed providers, and connection pooling for serverless.