setup-guides

Turnstile bot protection on auth (signup / signin / password reset)

Why: we saw scripted signup abuse — harvested email lists + Gmail dot-aliased addresses (t.h.e.o.d.or.ublake@gmail.com, m...o.b.i.li..alm.eid.a@gmail.com), identical Chrome/Desktop/Mac fingerprint, zero notes. Email confirmation was off, so nothing stopped a script from POSTing the signup form.

This wires Cloudflare Turnstile into the auth forms, paired with Supabase Auth's native CAPTCHA. The widget yields a token; the app passes it to supabase.auth.* via options.captchaToken; Supabase verifies it server-side with the secret key.

Code (already shipped)

Web:

  • TurnstileWidget.tsx — renders the Cloudflare widget (no npm dep; loads the CF script). Exposes isTurnstileEnabled().
  • Wired into all three password-auth forms — signup, signin, forgot-password. This is required: Supabase CAPTCHA is project-global, so once enabled, every auth endpoint demands a token. (OAuth/Google is unaffected — the redirect flow doesn't take a captchaToken.)

Mobile (React Native / Expo):

  • TurnstileWidget.tsx — same provider + same site key, but Turnstile only runs in a browser, so the mobile widget hosts the challenge in a react-native-webview and bridges the token back via postMessage. Same isTurnstileEnabled() / onVerify / resetSignal API as the web widget.
  • Wired into both mobile password-auth screens — SignInScreen and SignUpScreen (mobile has no separate forgot-password screen).
  • Domain gotcha: the WebView loads raw HTML (no origin), so it sets baseUrl to https://hivejournal.com to satisfy Turnstile's hostname check. That hostname must stay on the site key's allowed-hostnames list (it already is — see step 1). Change the widget's BASE_URL if you ever drop that host.

Safe by default: with no site key set, isTurnstileEnabled() is false, the widget renders nothing, callers send no token, and submit isn't gated. So the code is a no-op until you complete the two steps below.

Activation (two steps — both required)

1. Create a Turnstile widget (Cloudflare)

  1. Cloudflare dashboard → TurnstileAdd site.
  2. Add hostnames: hivejournal.com, lovio.io, write.cafe, graphene.fm, dreampro.io (apex; *. covers www/staging if you use a wildcard), plus localhost for dev.
  3. Widget mode: Managed (recommended).
  4. Copy the Site Key (public) and Secret Key (private).

2. Wire the keys

  • Frontend (Vercel env): set NEXT_PUBLIC_TURNSTILE_SITE_KEY=<site key> for Production (and Preview/dev as desired). Redeploy so it's inlined.
  • Mobile (Expo env): set EXPO_PUBLIC_TURNSTILE_SITE_KEY=<same site key> in apps/mobile/.env (and in the EAS build profile / env for release builds). Rebuild the JS bundle so it's inlined. Same site key as the frontend.
  • Supabase (dashboard): Authentication → SettingsBot & Abuse Protection (a.k.a. CAPTCHA / Attack Protection) → enable, choose provider Turnstile, paste the Secret Key, save.

The secret + at least one site key must be set. Site key only → the widget renders but Supabase doesn't verify (no protection). Secret only → Supabase rejects every auth call because the app sends no token. Because Supabase CAPTCHA is project-global, enabling the secret gates both web and mobile — so set the mobile site key at the same time, or mobile auth will break.

Verify

After both are live, a signup on lovio.io/auth/signup shows the Turnstile widget; submit is disabled until it solves. A scripted POST without a valid token gets rejected by Supabase (captcha verification process failed).

Notes

  • Tokens are single-use; the widget re-challenges after each submit (resetSignal), so retries (e.g. after an "already registered" error) work.
  • This stops scripted form abuse. It does not dedupe Gmail dot-aliases for a human attacker with a real inbox — pair with email confirmation (RESEND_SMTP_SUPABASE_SETUP.md) and the optional Gmail-normalization dedup for defense in depth.
TURNSTILE BOT PROTECTION — Docs | HiveJournal