Bot defense with Turnstile: one widget, three forms, honest failure
Forms attract bots. Signup attracts fake accounts, sign-in attracts credential stuffing, checkout attracts card testing. The Lumina demo puts Cloudflare Turnstile on all three — and the interesting part is not the widget, it’s the discipline around it.
The story
Turnstile is Cloudflare’s CAPTCHA successor: invisible most of the time, and it issues a one-time token that the server must verify — the client-side widget alone proves nothing. The demo’s rule: submission is gated on a fresh challenge, every time.
The signup button (and the sign-in button, and the checkout button) stays disabled until the challenge passes, then re-disables after every submission. That second half matters: tokens are single-use, so a “submit again” click with a stale token would silently fail forever. Each submission forces a fresh challenge before the button lights up again.
How it works
The split is the classic one — public sitekey to render the widget, secret to verify server-side:
// GET /api/config → the sitekey (localhost automatically gets the always-pass test key)
return jsonResponse({
appName: env.APP_NAME,
turnstileSiteKey: isLocal ? '1x00000000000000000000AA' : env.TURNSTILE_SITE_KEY,
});
// Server-side: the only proof that means anything
export async function verifyTurnstile(
secret: string,
token: string | undefined,
remoteip: string,
): Promise<boolean> {
if (!token) return false;
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, response: token, remoteip }),
});
const data = (await result.json()) as { success: boolean };
return data.success === true;
}
Note what’s not here: no trusting the client. If turnstileToken is missing, verification
fails. Checkout is gated exactly the same way as signup — a bot that scripts POST /api/checkout
hits the 403 before any inventory math runs.
Behind Turnstile sits a small in-memory rate limiter for the per-IP abuse the challenge itself doesn’t cover (and it’s honest about being demo-grade — per-isolate, in-memory):
const trackers = new Map<string, number[]>();
export function isRateLimited(key: string, { windowMs, max }: RateLimitOptions): boolean {
const recent = (trackers.get(key) ?? []).filter((t) => t > Date.now() - windowMs);
if (recent.length >= max) return true;
recent.push(Date.now());
trackers.set(key, recent);
return false;
}
Sign-in uses it twice — 8 attempts per 5 minutes per IP and 10 per 5 minutes per email — with an identical generic 401 whether the email is unknown, the account is passwordless, or the password is wrong, so error responses leak nothing.
What the demo shows
- Signup: the dedicated
/signuppage — invisible widget, button enables on challenge pass. - Sign-in:
/loginwith the same pattern for the demo roster. - Checkout: the drawer’s Place-order button is challenge-gated like the rest.
- Random user generator:
POST /api/users/randomruns the identical gate with Faker-generated identities — useful for populating the admin panel, and proof the gate isn’t special-cased to the pretty form.
Evidence: what to capture
-
/signupwith the Turnstile widget rendered and the submit button disabled →03-signup-widget.png - Submit a signup: button re-disables after the attempt →
03-signup-rechallenge.png -
/loginchallenge-gated sign-in →03-login-widget.png - Cloudflare dashboard → Turnstile → the widget’s analytics (solves, traffic) →
03-turnstile-analytics.png - (Optional) checkout drawer with the challenge-gated Place-order button →
03-checkout-gate.png
Key takeaways
- Client-side widgets are UX; server-side
siteverifyis the actual control. Never ship one without the other. - Re-gate after every submission — single-use tokens mean “one challenge per attempt”, not “one challenge per page load”.
- Test keys (
1x000…AA) make local development painless: the widget always passes, and production keys stay domain-locked.