Skip to content

Bot protection (Cloudflare Turnstile)

The signup, login, forgot-password and guest create-account forms, plus the "Resend code" step of email confirmation, carry a Cloudflare Turnstile widget. The token it produces is verified by Supabase Auth, not by this app. This page records why that split was chosen, what each environment needs, and how to prove it works.

Why Supabase verifies the token, not a Worker

Signup is browser-driven: supabase.auth.signUp() posts straight from the visitor's browser to the Supabase Auth API with the public anon key that ships in every bundle. A bot never needs our HTML form. Any siteverify call placed in this app (or a dedicated siteverify Worker) would guard the form and leave that API path open, which is exactly where automated signups come from.

Supabase Auth has captcha protection built in. With it on, gotrue runs the Turnstile siteverify call itself for every captcha-gated endpoint, whoever the caller is:

EndpointOur caller
POST /signup/signup (browser client), /guest/create-account (SSR)
POST /token?grant_type=password/login form action
POST /recover/forgot-password form action
POST /resendOtpVerify "Resend code"
POST /magiclink, POST /otpnot used by the app

Not gated (and so untouched by the widget): /verify (the OTP and recovery redemption in OtpVerify, /reset-password and /confirm-email-change), /user (settings updates), refresh-token grants, sign-out, and every /admin/* call. Requests made with the service-role key skip the check inside gotrue, so admin scripts, the Playwright entitlement helpers and the MCP worker are unaffected.

This is the gotrue route table as of September 2026 (internal/api/api.go in supabase/auth); re-check it before relying on a new auth call being exempt.

What the app does

  • src/lib/server/turnstile.ts resolves the site key (resolveTurnstileSiteKey, fails closed in production) and reads the posted token off a form (captchaTokenFromForm, the cf-turnstile-response field Turnstile injects).
  • src/routes/(auth)/+layout.server.ts supplies turnstileSiteKey to every (auth) page; the guest create-account load does the same for its route.
  • src/lib/components/auth/TurnstileWidget.svelte renders the widget (explicit rendering, one script loader per page). Forms wait for its token before enabling submit, forward the token as captchaToken on the auth call, and call reset() after every attempt because tokens are single-use. OtpVerify uses execute mode: the challenge runs invisibly when "Resend code" is clicked and only appears if Cloudflare needs an interaction.
  • authErrorMessage maps gotrue's captcha_failed answers to one friendly line that tells the visitor to refresh.

No site key configured means no widget and no token, which is correct only while captcha protection is off for that Supabase project.

Keys and where they live

EnvironmentApp Worker CF_TURNSTILE_KEY (site key, secret binding)Supabase Auth → Attack Protection
ProductionThe show.fm widget's site key (the widget's hostname show.fm covers my.show.fm)Turnstile, the show.fm widget's secret key
Staging1x00000000000000000000AA (Cloudflare's always-pass test site key)Turnstile, 1x0000000000000000000000000000000AA (always-pass test secret)
LocalCF_TURNSTILE_KEY=1x00000000000000000000AA in .envsupabase/config.toml [auth.captcha] holds the same test secret

Two rules follow from the table:

  • The site key and the secret must be a pair. A real site key against the test secret (or the reverse) fails every submission with captcha verification process failed. Because .env normally points pnpm dev at the staging Supabase branch, .env must carry the test site key; the real key belongs on the production Worker only.
  • Staging uses the test pair deliberately. The production widget is bound to show.fm and refuses my.showfm.dev, and a real managed challenge would block the headless Playwright login that every E2E suite starts with. With the test pair the whole token path still runs end to end (widget renders, token is posted, gotrue calls siteverify) and always passes. To rehearse a real challenge on staging, add showfm.dev to the widget's hostnames and swap both halves of the pair together.

CF_TURNSTILE_SECRET on the app Worker is not read by anything; the secret is Supabase-side only.

Rollout order (per environment)

  1. Deploy the app with CF_TURNSTILE_KEY set. The widget renders and tokens are posted; while captcha protection is still off, gotrue ignores them.
  2. Supabase dashboard → Authentication → Attack Protection → Enable Captcha protection → provider Turnstile → paste the matching secret → Save.
  3. Verify (below). If anything is wrong, switching captcha protection off in the dashboard restores every form immediately; the app needs no redeploy.

Never do step 2 before step 1: with captcha on and no widget deployed, every signup and login fails.

Verification

Exercise every gated form after the dashboard toggle:

  • /signup: create an account, confirm with the emailed code, click Resend code once.
  • /login: sign in; then submit a wrong password and sign in again on the same page (proves the widget resets between attempts).
  • /forgot-password: request a reset link.
  • /guest/create-account: create a guest account from a portal link.
  • Negative: from a terminal, POST <SUPABASE_URL>/auth/v1/signup with the anon key and no gotrue_meta_security.captcha_token must answer 400 captcha_failed. That is the direct-API path the widget exists to close.

On staging the always-pass pair makes every widget succeed instantly; the negative check still fails as expected because no token is present.

What an unconfirmed signup owns: nothing

The widget stops most automated signups; the database makes the ones that get through inert. Since migration 20260906200000_provision_on_email_confirmation.sql:

  • The four provisioning triggers on auth.users (handle_new_user, bootstrap_billing_account_for_user, link_orphaned_guests_to_user, link_podcast_guests_to_new_user) fire when the email is confirmed (email_confirmed_at NULL to timestamp), or on INSERT only for a row created already confirmed. An unconfirmed row has no profile, no billing account and no guest links.
  • The reverse direction is gated too: link_episode_guest_to_user (BEFORE INSERT on episode_guests) matches confirmed users only, so a host inviting an address that has an unconfirmed signup does not hand that row to it; the confirmation linkers pick it up once the address is proven. It is the only by-email auth.users lookup that assigns a link.
  • De-provisioning takes a FOR UPDATE lock on the auth.users row, so a confirmation arriving while the reaper works waits behind the delete instead of provisioning an account whose rows are being removed; the reaper re-checks under the lock and leaves a row that confirmed meanwhile alone.
  • crm-sync ingests only confirmed users (email_confirmed_at IS NOT NULL), so no CRM contact or signup event exists for them.
  • The guest create-account form no longer reads linking state before confirmation; My episodes shows the linked episodes once the code is verified.
  • reap_unconfirmed_users(interval) deletes stale unconfirmed, non-invited rows (default seven days) from the lifecycle-manager daily lane; deprovision_unconfirmed_user(uuid) undoes legacy provisioning and refuses a confirmed user. Both are SECURITY DEFINER, executable by service_role only.
  • pgTAP fixtures insert users with email_confirmed_at = NOW(); a fixture without it models an unconfirmed signup and gets nothing.

Residual, by design of Supabase Auth: a second signup for an unconfirmed address re-sends the code and never changes the stored password. If someone starts a signup with an address they do not own, the real owner who later confirms it inherits that password until they reset it. Nothing is provisioned for the attacker in the meantime, and the reaper removes the row after seven days if nobody confirms.

Test keys

BehaviourSite keySecret key
Always passes (visible)1x00000000000000000000AA1x0000000000000000000000000000000AA
Always passes (invisible)1x00000000000000000000BB1x0000000000000000000000000000000AA
Always blocks2x00000000000000000000AB2x0000000000000000000000000000000AA
Forces an interactive check3x00000000000000000000FF1x0000000000000000000000000000000AA

Swap the site key to 3x…FF (with the always-pass secret) to see the interactive state of every form, including the invisible resend challenge surfacing under the OTP form.

Internal documentation - Not for public distribution