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). ExposesisTurnstileEnabled().- 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 areact-native-webviewand bridges the token back viapostMessage. SameisTurnstileEnabled()/onVerify/resetSignalAPI as the web widget.- Wired into both mobile password-auth screens —
SignInScreenandSignUpScreen(mobile has no separate forgot-password screen). - Domain gotcha: the WebView loads raw HTML (no origin), so it sets
baseUrltohttps://hivejournal.comto 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'sBASE_URLif 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)
- Cloudflare dashboard → Turnstile → Add site.
- Add hostnames:
hivejournal.com,lovio.io,write.cafe,graphene.fm,dreampro.io(apex;*.coverswww/stagingif you use a wildcard), pluslocalhostfor dev. - Widget mode: Managed (recommended).
- 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>inapps/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 → Settings → Bot & 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.