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:
| Endpoint | Our 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 /resend | OtpVerify "Resend code" |
POST /magiclink, POST /otp | not 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.tsresolves the site key (resolveTurnstileSiteKey, fails closed in production) and reads the posted token off a form (captchaTokenFromForm, thecf-turnstile-responsefield Turnstile injects).src/routes/(auth)/+layout.server.tssuppliesturnstileSiteKeyto every(auth)page; the guest create-account load does the same for its route.src/lib/components/auth/TurnstileWidget.svelterenders the widget (explicit rendering, one script loader per page). Forms wait for its token before enabling submit, forward the token ascaptchaTokenon the auth call, and callreset()after every attempt because tokens are single-use.OtpVerifyuses execute mode: the challenge runs invisibly when "Resend code" is clicked and only appears if Cloudflare needs an interaction.authErrorMessagemaps gotrue'scaptcha_failedanswers 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
| Environment | App Worker CF_TURNSTILE_KEY (site key, secret binding) | Supabase Auth → Attack Protection |
|---|---|---|
| Production | The show.fm widget's site key (the widget's hostname show.fm covers my.show.fm) | Turnstile, the show.fm widget's secret key |
| Staging | 1x00000000000000000000AA (Cloudflare's always-pass test site key) | Turnstile, 1x0000000000000000000000000000000AA (always-pass test secret) |
| Local | CF_TURNSTILE_KEY=1x00000000000000000000AA in .env | supabase/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.envnormally pointspnpm devat the staging Supabase branch,.envmust 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.fmand refusesmy.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, addshowfm.devto 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)
- Deploy the app with
CF_TURNSTILE_KEYset. The widget renders and tokens are posted; while captcha protection is still off, gotrue ignores them. - Supabase dashboard → Authentication → Attack Protection → Enable Captcha protection → provider Turnstile → paste the matching secret → Save.
- 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/signupwith the anon key and nogotrue_meta_security.captcha_tokenmust answer 400captcha_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_atNULL 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 onepisode_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-emailauth.userslookup that assigns a link. - De-provisioning takes a
FOR UPDATElock on theauth.usersrow, 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-syncingests 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 areSECURITY DEFINER, executable byservice_roleonly.- 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
| Behaviour | Site key | Secret key |
|---|---|---|
| Always passes (visible) | 1x00000000000000000000AA | 1x0000000000000000000000000000000AA |
| Always passes (invisible) | 1x00000000000000000000BB | 1x0000000000000000000000000000000AA |
| Always blocks | 2x00000000000000000000AB | 2x0000000000000000000000000000000AA |
| Forces an interactive check | 3x00000000000000000000FF | 1x0000000000000000000000000000000AA |
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.
Related
- Staging Environment for the per-worker secret matrix.
- SvelteKit Routing for the
(auth)route group. supabase/config.toml[auth.captcha]for the local pairing.