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 /signup page — invisible widget, button enables on challenge pass.
  • Sign-in: /login with 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/random runs 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

  • /signup with 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
  • /login challenge-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 siteverify is 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.