Ai-Mee Help Centre
Home
Features
How-To Guides
FAQ
Need Help?
Home
Features
How-To Guides
FAQ
Need Help?

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:

  1. Supabase Auth email sends. signInWithOtp / signUp are 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 at signup.vue:38-49 that pushes visitors to /register-interest is cosmetic; the endpoint underneath is wide open.
  2. The waitlist / referral programme. /register-interest calls supabase.rpc('register_interest') directly from the browser. grant execute … to anon at front-end/supabase/migrations/20260805093954_referral_programme.sql:318, and register_interest_leads also carries a bare GRANT INSERT … TO anon with a WITH 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.
  3. 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

TurnstilehCaptchareCAPTCHA
Supported natively by Supabase Auth✅✅❌
CostFree, unlimited verificationsFree tier, paid ProNow 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 interactionVisible widgetScore-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: /verify has no captcha middleware, so the verifyOtp({ token_hash }) calls in front-end/src/stores/user-session.ts:155 (admin impersonation) and front-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_LOGIN path (front-end/src/plugins/session-check.ts:29) uses grant_type=password headlessly. 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)

  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.
  2. Supabase dashboard → Auth → Bot and Abuse Protection → enable, provider Turnstile, paste the secret. This is a hosted-project setting; .github/workflows/supabase.yml runs db push only, so it is not deployed from the repo.
  3. front-end/supabase/config.toml — uncomment [auth.captcha] but leave enabled = false with a comment explaining why (dev auto-login and the E2E suite both use headless grant_type=password). Local runs exercise the widget but not the enforcement.
  4. 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-model the token, expose reset(), 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 when VITE_TURNSTILE_SITE_KEY is unset, so local dev and E2E are unaffected by default.
  5. Wire it into the two live forms:
    • front-end/src/pages/auth/index.vue — inside the existing <form>, after the last VField, before the submit Button. Pass options: { captchaToken } on signInWithOtp (line 67) and signInWithPassword (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 in signup.vue:51-77 to require the token only when a sitekey is configured.
  6. front-end/src/composables/supabase.ts — add a fifth expected-failure predicate alongside isExpectedAuthRateLimit so 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) → POSTs secret + response to https://challenges.cloudflare.com/turnstile/v0/siteverify, returns { ok } | { error, code } per the house error convention. Omit remoteip: Fastify has no trustProxy, so request.ip is Traefik's address and a wrong remoteip makes siteverify fail. Fail closed when TURNSTILE_SECRET_KEY is empty.
  • api/src/routes/public.ts — POST /public/register-interest, TypeBox body mirroring the RPC's ten p_* params plus captchaToken. Verify, then call the RPC through createAdminSupabaseClient() (the caller has no session, so RLS cannot apply here — same posture as the bot/cron paths). Log with request.log.
  • api/src/index.ts — add /public/ to skipAuthPrefixes (~line 187). While in there, drop the dead /auth/telegram-signup entry, which currently makes any future route under that prefix silently public.
  • front-end/src/pages/register-interest.vue — swap supabase.rpc(...) for useApiFetch against 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; and revoke insert on public.register_interest_leads from anon, authenticated; plus dropping the "Public can register interest" policy. service_role keeps 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 from master on CI success, the SPA from the Cloudflare Pages app — they are not atomic).

Config

WhereKeyValue
Doppler ai-mee (all configs)TURNSTILE_SECRET_KEYsecret
api/src/config/env.tsTURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || ''follows the existing convention
api/.env.exampleTURNSTILE_SECRET_KEY=
Cloudflare Pages envVITE_TURNSTILE_SITE_KEYsitekey (public; build-time)
front-end/.env.productionVITE_TURNSTILE_SITE_KEYsitekey
scripts/cloud-session-start.sh:177-184 and scripts/setup-worktree.sh:51-59VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AACloudflare's always-passes test key, so sandbox sessions render a real widget
Supabase dashboardTurnstile secretmanual, not in the repo

Testing

Test-gate mapping (CLAUDE.md §Testing):

ChangedRequired test
api/src/services/turnstile.service.tsapi/tests/services/turnstile.service.test.ts (name match)
api/src/routes/public.tsapi/tests/routes/public.test.ts (name match)
api/src/index.tsexempt (barrel/entry) — behaviour covered by the route test
api/src/config/env.tsexempt (config/)
front-end/src/components/base/TurnstileWidget.vuefront-end/tests/unit/components/base/TurnstileWidget.spec.ts
front-end/src/pages/auth/*.vue, register-interest.vueupdate front-end/tests/unit/pages/auth/index.spec.ts, signup.spec.ts, register-interest.spec.ts
migrationexempt (migrations/)

Specifics:

  • Turnstile's test keys make both halves deterministic — sitekeys 1x00000000000000000000AA (always passes) / 2x00000000000000000000AB (always fails), secrets 1x0000000000000000000000000000000AA (passes) / 2x0000000000000000000000000000000AA (fails) / 3x0000000000000000000000000000000AA (token already spent).
  • front-end/tests/unit/pages/auth/index.spec.ts:199-235 asserts toHaveBeenCalledWith({ email, options: { emailRedirectTo } }) — an exact object match that will fail once captchaToken is added. Update it deliberately rather than loosening it to objectContaining.
  • The HTML snapshot at tests/unit/pages/auth/__snapshots__/index.spec.ts.snap and the dark-mode visual baselines tests/e2e/visual-dark-mode.spec.ts-snapshots/auth-login-*.png will change if the widget renders. Keeping VITE_TURNSTILE_SITE_KEY unset 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.ts to stub the new POST /public/register-interest route (the existing specs already stub at the HTTP layer via page.route).
  • Manual end-to-end, after deploy: submit /register-interest on production and confirm a row lands; then curl the RPC directly with the anon key and confirm a 42501 permission-denied; then request a magic link with a request that omits captcha_token and confirm GoTrue returns 422 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, and trustProxy being 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 _headers file, no helmet), so adding Turnstile's script needs no CSP change now — but whoever adds one must allowlist challenges.cloudflare.com alongside Crisp, GA, Meta Pixel and Sentry. Note the aspirational snippet in docs/migration/phase-6-production-hardening.md:475-490 uses scriptSrc: ["'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-preview is unguarded and not noindex.
  • /track/* pixel and redirector abuse.