Bot protection: Cloudflare Turnstile
Plan for #766 — "implement recapture or a similar product like Cloudflare for protecting against bots".
Context
Every public write path in the product is currently unprotected. There is no CAPTCHA, no @fastify/rate-limit, no honeypot, and no WAF in front of either the SPA or the API. Three surfaces cost real money or real damage when farmed:
- Supabase Auth email sends.
signInWithOtp/signUpare called straight from the browser (front-end/src/pages/auth/index.vue:67,front-end/src/pages/auth/signup.vue:91,107,126). Anyone can hit the GoTrue endpoint directly and make us send transactional email to arbitrary addresses — per-send cost plus sender-reputation damage. The client-side redirect atsignup.vue:38-49that pushes visitors to/register-interestis cosmetic; the endpoint underneath is wide open. - The waitlist / referral programme.
/register-interestcallssupabase.rpc('register_interest')directly from the browser.grant execute … to anonatfront-end/supabase/migrations/20260805093954_referral_programme.sql:318, andregister_interest_leadsalso carries a bareGRANT INSERT … TO anonwith aWITH CHECK (true)policy (20260514160000_register_interest_leads.sql:16,23). Referral codes move you up the queue and unlock early access, so a scripted signup loop is a direct incentive, not just noise. - Tracking pixels (
/track/open,/track/click) — unauthenticated table writes and an open-ish 302 redirector. Out of scope here (a captcha cannot gate a pixel); noted for a rate-limit follow-up.
The intended outcome: a bot has to solve a challenge before it can make us send an email or write a waitlist/referral row, with no visible friction for a normal human.
Recommendation: Cloudflare Turnstile
| Turnstile | hCaptcha | reCAPTCHA | |
|---|---|---|---|
| Supported natively by Supabase Auth | ✅ | ✅ | ❌ |
| Cost | Free, unlimited verifications | Free tier, paid Pro | Now runs on reCAPTCHA Enterprise — needs a GCP billing account past the free quota |
| Vendor already in the stack | ✅ Cloudflare Pages hosts the SPA | ❌ | ❌ |
| UX | "Managed" widget — usually zero interaction | Visible widget | Score-based, or image puzzles on v2 |
Turnstile wins on all four axes, and the second row is close to decisive: Supabase Auth's built-in captcha hook supports only hCaptcha and Turnstile, so choosing reCAPTCHA would mean hand-rolling protection for the auth endpoints that GoTrue already covers for free. Turnstile also does not require the API to be proxied through Cloudflare — it is a plain script + siteverify call, so api.ai-mee.uk behind Traefik stays as it is.
The stub is already sitting in the repo, commented out, at front-end/supabase/config.toml:149-153.
What Supabase's built-in captcha actually covers
Verified against GoTrue's router (internal/api/api.go) rather than the docs, because the blast radius matters here. verifyCaptcha is attached to:
/signup, /recover, /resend, /magiclink, /otp, /sso, and /token — but isIgnoreCaptchaRoute exempts /token for grant_type of refresh_token, pkce and id_token, and the whole middleware is skipped when the request carries admin credentials.
Consequences for us:
- ✅ Covered once enabled:
signInWithOtp,signUp,signInWithPassword. - ✅ Not broken:
/verifyhas no captcha middleware, so theverifyOtp({ token_hash })calls infront-end/src/stores/user-session.ts:155(admin impersonation) andfront-end/src/pages/chat/review.vue:62(Telegram mini-app deep link) keep working with no widget. Session refresh and the Google OAuth PKCE exchange are exempt too, as is anything the API does with the service-role key (admin.generateLink). - ⚠️ Would break: the
VITE_DEV_AUTO_LOGINpath (front-end/src/plugins/session-check.ts:29) usesgrant_type=passwordheadlessly. Mitigation: leave captcha disabled on the local Supabase instance (see Layer 1 below). - ❌ Not covered:
rpc('register_interest')and the direct anon INSERT — these are PostgREST, not GoTrue. That is Layer 2.
Design
Layer 1 — Supabase Auth captcha (covers surface 1)
- Cloudflare dashboard → Turnstile → new widget, "Managed" mode, hostnames
ai-mee.uk,*.ai-marketing-55e.pages.dev,aimee.pages.dev,localhost. Yields a sitekey (public) and secret key. - Supabase dashboard → Auth → Bot and Abuse Protection → enable, provider Turnstile, paste the secret. This is a hosted-project setting;
.github/workflows/supabase.ymlrunsdb pushonly, so it is not deployed from the repo. front-end/supabase/config.toml— uncomment[auth.captcha]but leaveenabled = falsewith a comment explaining why (dev auto-login and the E2E suite both use headlessgrant_type=password). Local runs exercise the widget but not the enforcement.- New component
front-end/src/components/base/TurnstileWidget.vue— explicit rendering (https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit), which is what Cloudflare recommends for SPAs.v-modelthe token, exposereset(), load the script once and idempotently. No wrapper library: the three Vue packages on npm are thin, and two are unmaintained —turnstile.render()/turnstile.reset()is ~40 lines. Renders nothing whenVITE_TURNSTILE_SITE_KEYis unset, so local dev and E2E are unaffected by default. - Wire it into the two live forms:
front-end/src/pages/auth/index.vue— inside the existing<form>, after the lastVField, before the submitButton. Passoptions: { captchaToken }onsignInWithOtp(line 67) andsignInWithPassword(line 84).front-end/src/pages/auth/signup.vue— same, on lines 91, 107 and 126.- Reset the widget after every submit — Turnstile tokens are single-use and expire after 300s.
- Validation: follow the
.superRefine()idiom already insignup.vue:51-77to require the token only when a sitekey is configured.
front-end/src/composables/supabase.ts— add a fifth expected-failure predicate alongsideisExpectedAuthRateLimitso a user failing a challenge (422 captcha_failed) doesn't page us in Sentry.
Layer 2 — Waitlist RPC behind a verified API route (covers surface 2)
Supabase's captcha hook cannot reach PostgREST, so the token has to be verified by us. Per CLAUDE.md ("Only create an API route when server-side logic, secrets, or third-party calls are involved") a third-party call with a secret is exactly the carve-out.
api/src/services/turnstile.service.ts—verifyTurnstileToken(token)→ POSTssecret+responsetohttps://challenges.cloudflare.com/turnstile/v0/siteverify, returns{ ok } | { error, code }per the house error convention. Omitremoteip: Fastify has notrustProxy, sorequest.ipis Traefik's address and a wrongremoteipmakes siteverify fail. Fail closed whenTURNSTILE_SECRET_KEYis empty.api/src/routes/public.ts—POST /public/register-interest, TypeBox body mirroring the RPC's tenp_*params pluscaptchaToken. Verify, then call the RPC throughcreateAdminSupabaseClient()(the caller has no session, so RLS cannot apply here — same posture as the bot/cron paths). Log withrequest.log.api/src/index.ts— add/public/toskipAuthPrefixes(~line 187). While in there, drop the dead/auth/telegram-signupentry, which currently makes any future route under that prefix silently public.front-end/src/pages/register-interest.vue— swapsupabase.rpc(...)foruseApiFetchagainst the new route, and add the widget.- New migration (
pnpm supabase migration new lock_down_register_interest):revoke execute on function public.register_interest(…) from anon, authenticated;andrevoke insert on public.register_interest_leads from anon, authenticated;plus dropping the"Public can register interest"policy.service_rolekeeps everything. Ordering matters — the revoke must ship after the API route and the front-end are live, or the waitlist form 403s in the window between deploys. Two PRs, or one PR merged after confirming the API deploy went out (the API deploys frommasteron CI success, the SPA from the Cloudflare Pages app — they are not atomic).
Config
| Where | Key | Value |
|---|---|---|
Doppler ai-mee (all configs) | TURNSTILE_SECRET_KEY | secret |
api/src/config/env.ts | TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || '' | follows the existing convention |
api/.env.example | TURNSTILE_SECRET_KEY= | |
| Cloudflare Pages env | VITE_TURNSTILE_SITE_KEY | sitekey (public; build-time) |
front-end/.env.production | VITE_TURNSTILE_SITE_KEY | sitekey |
scripts/cloud-session-start.sh:177-184 and scripts/setup-worktree.sh:51-59 | VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA | Cloudflare's always-passes test key, so sandbox sessions render a real widget |
| Supabase dashboard | Turnstile secret | manual, not in the repo |
Testing
Test-gate mapping (CLAUDE.md §Testing):
| Changed | Required test |
|---|---|
api/src/services/turnstile.service.ts | api/tests/services/turnstile.service.test.ts (name match) |
api/src/routes/public.ts | api/tests/routes/public.test.ts (name match) |
api/src/index.ts | exempt (barrel/entry) — behaviour covered by the route test |
api/src/config/env.ts | exempt (config/) |
front-end/src/components/base/TurnstileWidget.vue | front-end/tests/unit/components/base/TurnstileWidget.spec.ts |
front-end/src/pages/auth/*.vue, register-interest.vue | update front-end/tests/unit/pages/auth/index.spec.ts, signup.spec.ts, register-interest.spec.ts |
| migration | exempt (migrations/) |
Specifics:
- Turnstile's test keys make both halves deterministic — sitekeys
1x00000000000000000000AA(always passes) /2x00000000000000000000AB(always fails), secrets1x0000000000000000000000000000000AA(passes) /2x0000000000000000000000000000000AA(fails) /3x0000000000000000000000000000000AA(token already spent). front-end/tests/unit/pages/auth/index.spec.ts:199-235assertstoHaveBeenCalledWith({ email, options: { emailRedirectTo } })— an exact object match that will fail oncecaptchaTokenis added. Update it deliberately rather than loosening it toobjectContaining.- The HTML snapshot at
tests/unit/pages/auth/__snapshots__/index.spec.ts.snapand the dark-mode visual baselinestests/e2e/visual-dark-mode.spec.ts-snapshots/auth-login-*.pngwill change if the widget renders. KeepingVITE_TURNSTILE_SITE_KEYunset in the E2E env leaves both untouched; that is the intent of the "renders nothing without a sitekey" rule. - E2E: extend
front-end/tests/e2e/register-interest.spec.tsto stub the newPOST /public/register-interestroute (the existing specs already stub at the HTTP layer viapage.route). - Manual end-to-end, after deploy: submit
/register-intereston production and confirm a row lands; thencurlthe RPC directly with the anon key and confirm a42501permission-denied; then request a magic link with a request that omitscaptcha_tokenand confirm GoTrue returns422 captcha_failed.
Out of scope (deliberately)
Each of these is a real gap found while researching this, and each is its own issue:
@fastify/rate-limit+trustProxy. There is no rate limiting anywhere on the API, andtrustProxybeing unset means the one IP throttle that exists (api/src/services/referrals.service.ts:33,MAX_INVITES_PER_IP_PER_HOUR) collapses into a single shared bucket behind Traefik. A captcha does not fix either.- CSP. There is no Content-Security-Policy on the SPA today (no
_headersfile, no helmet), so adding Turnstile's script needs no CSP change now — but whoever adds one must allowlistchallenges.cloudflare.comalongside Crisp, GA, Meta Pixel and Sentry. Note the aspirational snippet indocs/migration/phase-6-production-hardening.md:475-490usesscriptSrc: ["'self'"], which would break all of them. enable_confirmations = false— accounts are usable before the email is verified, so a solved captcha buys unmetered access to the LLM routes. Email gating or per-user quotas is the actual control there./ai-previewis unguarded and notnoindex./track/*pixel and redirector abuse.