Skip to main content
vibestrap protects its public forms with Cloudflare Turnstile — a CAPTCHA replacement that’s privacy-friendly, free, and doesn’t drop any cookies. The widget is wired into the signup, forgot-password, and newsletter forms; the server-side helper at 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

  1. Create a Turnstile site in the Cloudflare dashboard. Add your production domain plus localhost for local dev. Pick “Managed” mode for the best UX (Cloudflare decides when to challenge).
  2. Set both env vars in .env.local:
    NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA...     # public, ships to client
    TURNSTILE_SECRET_KEY=0x4AAAAAAA...                # server-only, never exposed
    
  3. Confirm the feature flag in src/config/site.ts (already on by default):
    features: {
      enableTurnstile: true,
    },
    
  4. Restart pnpm dev. The widget renders on the signup, forgot-password, and newsletter forms. The server actions automatically call verifyTurnstile(token) and reject the request when the token is missing or invalid.

Verify it works

  1. Open /register in incognito — you should see the Turnstile widget (usually invisible, or a quick “verifying” flash).
  2. Open devtools → Network. On submit, the form posts a cf-turnstile-response field. The server action calls https://challenges.cloudflare.com/turnstile/v0/siteverify internally; you’ll see no extra outbound request from the browser.
  3. Try submitting with NEXT_PUBLIC_TURNSTILE_SITE_KEY removed — the widget disappears. Server-side, turnstileEnabled() returns false and verification is skipped (returns { ok: true, skipped: true }).
  4. Tamper with the token via devtools → server action returns an error, form does not submit.

Common pitfalls

  1. 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.
  2. 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.render with expired-callback.
  3. 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.
  4. Missing token in production. Common cause: TURNSTILE_SECRET_KEY set, but NEXT_PUBLIC_TURNSTILE_SITE_KEY not 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.
  5. Localhost not allow-listed. Add localhost (and 127.0.0.1 if you use it) to your Turnstile site’s domain list, or local signups will silently fail.

How it’s wired

// src/lib/turnstile.ts (excerpt)
export async function verifyTurnstile(token: string | undefined | null) {
  if (!siteConfig.features.enableTurnstile || !env.TURNSTILE_SECRET_KEY) {
    return { ok: true, skipped: true };
  }
  if (!token) return { ok: false, skipped: false, errors: ['missing-token'] };
  // POST to Cloudflare siteverify, return parsed result.
}
Server actions call 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).
Either path makes verifyTurnstile return { ok: true, skipped: true } so the rest of the action proceeds normally.

Official docs