# Auth Approaches: Zero-Friction Onboarding + Recovery Code **Status:** research / landscape doc, not a plan. **Constraint set:** self-hosted Supabase (Postgres + GoTrue + PostgREST + Realtime + Kong); Next.js App Router; no email / SMS / OAuth; recovery code is the sole credential; long-lived cookie sessions; RLS desirable. The current implementation (anonymous sign-in → synthetic `@moviedice.invalid` email + HKDF password promotion) is the system under reconsideration. Driving pain points: GoTrue [#2013](https://github.com/supabase/auth/issues/2013) (`admin.updateUserById` does not flip `is_anonymous`/`role`/`aud` reliably for promoted users), needing `EMAIL_ENABLED=true` to admit a synthetic email which re-exposes `/recover`, `/otp`, `/magiclink`, `PUT /user`, then plugging that with Kong denylists, plus a CHECK constraint on `auth.users.email` to keep the synthetic domain pinned. Officially: "There is no way to convert an anonymous user to a permanent user without sending a confirmation email" and "recovery codes are not supported in Supabase Auth" (Supabase docs, anon sign-in section). --- ## A. Stay on GoTrue anonymous + custom recovery layer (re-issue same-UID JWT) **Mechanism.** First visit: browser calls `signInAnonymously()`; GoTrue mints a JWT with `sub = `, `is_anonymous = true`, sets cookies via `@supabase/ssr`. Server route `/api/recovery/generate` creates a 24-char code, stores `argon2id(code)` in a custom `recovery_codes(user_id, hash)` table, returns plaintext once. On a new device the user pastes the code into `/api/recovery/claim`; the server scans `recovery_codes`, verifies with Argon2, then **mints a fresh GoTrue-shaped JWT (HS256 with `JWT_SECRET`) using the original `sub`**, sets the access + refresh cookies directly. GoTrue is never asked to "promote" anything. The user stays `is_anonymous=true` forever; the app treats that flag as meaningless. **Where the credential lives.** Recovery hash in a custom table you own. GoTrue still owns `auth.users` and the JWT signing secret. No synthetic email, no synthetic password, no `auth.users.email` CHECK constraint, no Kong denylists for `/recover|/otp|/magiclink` because `EMAIL_ENABLED` stays `false`. **RLS.** `auth.uid()` resolves natively (it reads `request.jwt.claims->>sub`). All existing policies keep working. `auth.jwt()->>'is_anonymous'` will read `true` — if any policy uses that, audit/relax it. **What we lose.** Refresh-token rotation has to be reimplemented if you mint your own access tokens (you can sidestep this by inserting a row into `auth.refresh_tokens` and letting GoTrue refresh as normal, but that is internal-schema coupling). You also lose `supabase.auth.signInWith*` ergonomics on claim — claim becomes a server-only flow that writes cookies directly. **Build cost.** ~250–400 LOC: a `recovery_codes` table + migration, two routes (generate/claim), a small JWT mint helper using `jose`, and cookie writes matching `@supabase/ssr` names. Throw away the synthetic identity module + the `00003` migration + Kong denylists. **Failure modes.** (1) Forging a refresh-token row by hand is fragile across GoTrue upgrades — safer to mint short-lived access JWTs with longer `exp` (e.g. 7 days) and re-mint on a "touch" endpoint, which loses GoTrue refresh semantics entirely. (2) If `JWT_SECRET` ever rotates, every minted token dies; same as today. (3) Anonymous-user RLS auditing: every policy that special-cases `is_anonymous` becomes a footgun because all your real users are flagged anonymous. (4) Argon2 scan over the recovery table is O(n) — needs a prefix index column (first N chars of code, hashed with a fast HMAC under a server pepper) to keep claim under ~50ms at scale. **Verdict.** Strong default. Removes the entire promotion fight and all the security review fallout from `EMAIL_ENABLED=true`, while keeping `auth.uid()`, GoTrue user rows, and Realtime auth working unchanged. The "all users are anonymous forever" framing is honest: in a passwordless system, the recovery code IS the only credential, so the GoTrue "promoted" bit was never carrying real meaning anyway. --- ## B. Custom JWT, no GoTrue for users at all **Mechanism.** Skip `signInAnonymously`. On first visit, `/api/auth/bootstrap` creates a row in a custom `app_users(id uuid pk, created_at, last_active_at)`, mints an HS256 JWT signed with `JWT_SECRET` carrying `{sub: id, role: 'authenticated', aud: 'authenticated', exp}`, sets it as an HttpOnly cookie. Recovery generate/claim work as in A but mint the same custom JWT. GoTrue runs only for the admin TOTP login (which already uses iron-session, not GoTrue — so GoTrue could even be removed for users entirely, kept only if admin ever migrates). **Where the credential lives.** Fully custom: `app_users` + `recovery_codes`. `auth.users` is unused (or kept only for admin/internal). **RLS.** `auth.uid()` is just `(current_setting('request.jwt.claims', true)::json->>'sub')::uuid` — works as long as the JWT has a `sub` claim and the `role` claim is `authenticated`. PostgREST and Realtime accept any HS256 token signed with the configured secret ([PostgREST auth docs](https://docs.postgrest.org/en/v12/references/auth.html); [Supabase realtime + custom JWT discussion #11826](https://github.com/orgs/supabase/discussions/11826)). FKs that point at `auth.users(id)` (`movies.added_by`, `users.id`) need to repoint at `app_users(id)`. **What we lose.** GoTrue session refresh, GoTrue user management UI in Studio (Studio's user list will be empty), any future ability to add OAuth/email "for real" without re-onboarding. Realtime Presence still works (it cares about JWT `sub`, not GoTrue). **Build cost.** ~400–700 LOC + a non-trivial migration that repoints every `auth.users(id)` FK. Token refresh and rotation must be hand-rolled (sliding-window cookie re-mint on each authenticated request is the simplest pattern — not great cryptographically but acceptable for this threat model). **Failure modes.** (1) Re-implementing JWT lifecycle is the classic "rolled my own auth" trap — token revocation, clock-skew on `exp`, refresh races. (2) If `JWT_SECRET` is ever exposed, every account is forgeable; same with GoTrue, but you've lost the layer of separation. (3) The whole Supabase Auth subsystem becomes dead weight in the compose file — tempting to remove, painful to add back. **Verdict.** Cleanest model on paper, highest carrying cost in practice. Right pick if you decide GoTrue is a net liability and want to delete it. Wrong pick if you might ever want OAuth or email later, or if Studio's user view is part of your ops workflow. --- ## C. Iron-session opaque cookies, no JWT for users **Mechanism.** First visit: server route creates `app_users` row, sets an iron-session encrypted HttpOnly cookie containing `{userId}`. Every API route reads the cookie, looks up the user, queries Postgres via the **service role key** with explicit `WHERE owner = $userId` filters in app code. Browser never speaks to PostgREST or Realtime directly. **Where the credential lives.** Server-encrypted cookie (iron-session AEAD); user identity in `app_users`. No JWT anywhere on the user path. Recovery code table same as A/B. **RLS.** Effectively bypassed — you're always service-role. Authorization moves entirely into app code (`WHERE` clauses on every query, `assertOwner()` helpers). For defense-in-depth you can still keep RLS enabled with deny-all for `anon`/`authenticated` and let service role bypass, but it's belt-and-suspenders. **What we lose.** Browser-side Supabase client is dead. Realtime browser subscriptions tied to user identity stop working — would need a server-side proxy (SSE / WebSocket from your Next.js routes) to fan out updates. For MovieDice today this matters: list pages subscribe to `movies` changes in-browser per `CLAUDE.md` ("Real-time: subscribe on mount, unsubscribe on unmount"). Replacing that is a significant rework. **Build cost.** ~300 LOC for auth, but **multiplied by every existing data-fetching site** that currently uses `createBrowserClient`. Plus a Realtime bridge if you keep that feature. Realistically 1500+ LOC of net change. **Failure modes.** (1) Forgetting a `WHERE owner = $userId` is a full IDOR — RLS was the safety net, now there is none. (2) Service role key in every route handler widens blast radius if any route is RCE'd or has SSRF. (3) Cookie size / rotation if you stuff anything beyond a uid. **Verdict.** Right pick when you want Postgres as a private datastore and don't need browser real-time. Wrong pick here — MovieDice depends on browser Realtime and on RLS as the authorization model. Migrating to opaque cookies is a re-architecture, not an auth swap. --- ## D. Off-the-shelf session library (better-auth, Lucia, Auth.js) **Mechanism (better-auth specifically).** Install `better-auth` with the [anonymous plugin](https://better-auth.com/docs/plugins/anonymous). First visit: `signInAnonymous()` issues a session cookie backed by a server-side `session` table (Postgres adapter). Recovery becomes a custom credential provider: code generation stores `argon2(code)` in a `recovery_codes` table; claim looks up the original anonymous user, calls `onLinkAccount({anonymousUser, newUser})` to merge — except for our case there _is_ no new user, you'd just rotate the session to the original user id. Lucia v3 [is being sunset by March 2025](https://github.com/lucia-auth/lucia/discussions/1714) — not viable. Auth.js (NextAuth) has no first-class anonymous concept; community recipes use a "Credentials" provider returning a synthetic user, which is essentially approach B with extra glue. **Where the credential lives.** better-auth's own tables (`user`, `session`, `account`) plus a custom `recovery_codes` table. **RLS.** Doesn't help — better-auth manages its own session cookie, not a Postgres-aware JWT. To keep RLS, you'd run better-auth alongside GoTrue (each route exchanges the better-auth session for a minted Supabase JWT) — at which point you're back to approach B with an extra moving part. **What we lose.** Tight Supabase coupling. `auth.uid()` doesn't resolve unless you bridge. **Build cost.** Lower for the auth plumbing (~150 LOC for the integration), higher for the bridge (~300 LOC + ongoing). Plus a new dependency surface in security review. **Failure modes.** (1) better-auth's anonymous plugin has known sharp edges ([#3658](https://github.com/better-auth/better-auth/issues/3658), [#3267](https://github.com/better-auth/better-auth/issues/3267)) for auto-sign-in. (2) Recovery flow isn't a built-in primitive — you build it anyway. (3) Adding a second auth system to a project that already has GoTrue + iron-session for admin = three session systems in one app. (4) "Self-hosted Supabase" memory implies committing to that stack; bypassing GoTrue with a third lib invites drift. **Verdict.** Net negative for this project. better-auth is well-designed but the value prop is "we handle auth providers for you" — when there's exactly one credential type (a recovery code), the library is mostly overhead. Right pick on a greenfield project that wants OAuth + magic links + passkeys + anonymous all in one. Wrong pick when the constraint is explicitly "no email, no OAuth, just one code." --- ## E. WebAuthn / passkeys as the recovery mechanism **Mechanism.** First visit: anonymous sign-in (any approach). User registers a passkey (resident/discoverable credential) bound to their account. On a new device, the user does `navigator.credentials.get()` — the OS picks a passkey, the server verifies the signed challenge, sets a session. **Where the credential lives.** Public key in a `webauthn_credentials` table; private key in the user's authenticator (OS keychain, hardware key, password manager). **RLS.** Same options as A/B — passkeys are just an alternative _claim_ mechanism, the session that follows can still be a Supabase JWT. **What we lose.** The "write a code on paper" mental model. Passkeys assume the user has a synced authenticator (iCloud Keychain, Google Password Manager, 1Password). If they don't, or they explicitly want a copy-paste-able recovery string, this fails the product requirement. **Build cost.** ~600–900 LOC (`@simplewebauthn/server` + browser plumbing + cross-device QR ceremony for "log in here from another device that already has the passkey"). **Failure modes.** Cross-device passkey transfer is OS-specific and still rough. Users without a passkey-capable browser/OS are locked out. The "I lose my phone" recovery story is now Apple's / Google's problem, not yours — which is great until iCloud Keychain has an outage. **Verdict.** Wrong fit for the stated requirement ("paste the code"). Right pick if the product evolves to "an account I want to use seriously" and the recovery code becomes a fallback rather than the primary mechanism. Worth keeping in mind as a _second_ recovery factor later, not a replacement. --- ## F. Hybrid: GoTrue anonymous JWT + UID indirection table **Mechanism.** GoTrue mints anonymous JWTs as today. A custom `app_user_aliases(app_uid pk, current_auth_uid)` table maps stable app identity to the current GoTrue uid. On recovery claim, server calls `signInAnonymously()` to mint a _new_ GoTrue user, then updates the alias row to repoint `app_uid → new_auth_uid`. App code reads `app_uid` from a separate signed cookie or a JWT custom claim. **Where the credential lives.** Recovery hash in custom table; identity split between `auth.users` (ephemeral) and `app_user_aliases` (stable). **RLS.** `auth.uid()` returns the _current_ GoTrue uid, not the stable app uid — every policy needs rewriting to `EXISTS (SELECT 1 FROM app_user_aliases WHERE current_auth_uid = auth.uid() AND app_uid = )`. That's a join per policy check. Or you stuff `app_uid` into a custom JWT claim, which means re-minting GoTrue tokens (back to approach A territory). **What we lose.** The simplicity of `auth.uid() = owner_id`. Every FK from `movies.added_by` etc. now has to point at `app_uid`, with a trigger or join to map. Old anonymous user rows in `auth.users` accumulate as garbage. **Build cost.** Conceptually small (~200 LOC) but the RLS rewrite + FK migration is high-risk. **Failure modes.** (1) Race during repoint: if a write happens mid-claim, it's attributed to the wrong `auth.uid()`. (2) `auth.users` table grows unboundedly with orphaned anonymous rows. (3) Two sources of truth for "who is this user" is exactly the bug surface that #2013 already taught us to avoid. **Verdict.** Strictly worse than A. The whole point of indirection is to avoid promotion, but A avoids promotion _without_ indirection by just re-minting a same-`sub` JWT. Skip. --- ## Comparison | Approach | Onboard friction | Recovery friction | Build cost | Blast radius on bugs | RLS-compatible | Breaks Realtime | Depends on GoTrue | | --------------------------------------------------------- | ----------------------- | ---------------------------- | ---------------------------------------------------- | ------------------------------------------------- | ------------------------------ | --------------------------- | ----------------- | | **A** GoTrue anon + custom recovery, re-mint same-sub JWT | none | paste code | low (~300 LOC, deletes more than it adds) | low (custom code is small + audited) | yes, native `auth.uid()` | no | yes (lightly) | | **B** Custom JWT, no GoTrue for users | none | paste code | high (~600 LOC + FK migration) | medium (you own the JWT lifecycle) | yes, but you own the claims | no | no | | **C** Iron-session opaque cookies | none | paste code | very high (auth + every data path + realtime bridge) | high (no RLS safety net; service role everywhere) | n/a (bypassed) | yes (browser realtime gone) | no | | **D** better-auth + bridge | none (with anon plugin) | custom flow anyway | medium (~450 LOC + new dep) | medium (third-party surface) | only via bridge | requires bridge | optionally | | **E** WebAuthn passkeys | none | passkey ceremony, not a code | high (~800 LOC) | low | yes | no | yes | | **F** GoTrue anon + alias indirection | none | paste code | medium (RLS rewrite is the cost) | high (race + orphan rows) | yes but every policy rewritten | no | yes | --- ## Recommendation **Pick A. Strong second is B if you've already concluded GoTrue is more trouble than it's worth.** Reasoning, plainly: - The current pain is entirely caused by trying to _change a user's nature_ in GoTrue (anonymous → permanent) when the product never actually needed that change. The product needs one thing: "given a valid recovery code, give the same user a fresh session." Approach A does exactly that and nothing more. No `EMAIL_ENABLED=true`, so no `/recover|/otp|/magiclink|PUT /user` exposure, so no Kong denylists. No synthetic email, so no `auth.users.email` CHECK. No promotion, so [#2013](https://github.com/supabase/auth/issues/2013) is irrelevant. `auth.uid()` continues to resolve, RLS policies are unchanged, browser Realtime keeps working. The `is_anonymous` flag on every user becomes meaningless metadata — but that's accurate; in a passwordless system every user is, by definition, only as identified as their recovery code. - The honest downside of A is that you're hand-minting JWTs with `JWT_SECRET` on the claim path, which means you're trusting `jose` (or equivalent) and your own cookie code instead of GoTrue's. That's ~50 lines of code, well-scoped, easy to test. Compare against the current footprint (synthetic email module, HKDF derivation, 2-step admin-API promote with rollback, post-condition verification, CHECK migration, Kong denylist for 4 endpoints, custom GoTrue config to enable email then plug it again) — A is _less_ code and a _smaller_ security surface, not more. - B is the right answer if the team ever decides "we don't actually use GoTrue's value-add, why is it in our compose file." Don't do B today; do A today, and if a year from now `signInAnonymously` is the only GoTrue feature you touch, retiring GoTrue is a one-week project and B is the destination. - Do **not** adopt C, D, E, or F as the primary mechanism. C breaks Realtime; D adds a dep without solving the actual problem; E doesn't match the "type the code" requirement; F is A with extra steps and worse RLS. **One tactical note for whoever implements A:** look at GoTrue's `auth.refresh_tokens` table before deciding whether to (a) hand-mint short-lived access tokens and re-mint on a touch endpoint, or (b) insert a refresh-token row directly so `supabase.auth.refreshSession()` keeps working in the browser. Option (a) is more decoupled from GoTrue internals; option (b) is more ergonomic. The trade-off is roughly "30 lines of code" vs "you'll have to re-verify on every GoTrue upgrade," and the right answer probably depends on how often you intend to upgrade GoTrue. --- ## Sources - [Supabase Auth #2013 — admin.updateUserById behavior with anonymous users](https://github.com/supabase/auth/issues/2013) - [Supabase Auth #1578 — updateUser vs admin.updateUserById with anonymous users](https://github.com/supabase/auth/issues/1578) - [Supabase docs — Anonymous Sign-Ins](https://supabase.com/docs/guides/auth/auth-anonymous) - [Supabase discussion #29017 — convert anonymous user to permanent with password](https://github.com/orgs/supabase/discussions/29017) - [Supabase docs — Users (recovery codes not supported, max 10 MFA factors)](https://supabase.com/docs/guides/auth/users) - [Supabase discussion #11826 — Realtime with custom JWT](https://github.com/orgs/supabase/discussions/11826) - [Supabase discussion #18273 — How to create a custom JWT](https://github.com/orgs/supabase/discussions/18273) - [Supabase docs — JSON Web Tokens](https://supabase.com/docs/guides/auth/jwts) - [PostgREST 12 Authentication reference](https://docs.postgrest.org/en/v12/references/auth.html) - [better-auth — Anonymous plugin](https://better-auth.com/docs/plugins/anonymous) - [better-auth #3658 — anonymous plugin after-hook bug](https://github.com/better-auth/better-auth/issues/3658) - [Lucia v3 docs](https://v3.lucia-auth.com/) (note: Lucia is being sunset) - [Lucia #1475 — Feature Request: Anonymous/Guest Sessions](https://github.com/lucia-auth/lucia/issues/1475) - [Standard Notes — Encryption whitepaper](https://standardnotes.com/help/security/encryption) - [Bitwarden — Two-step recovery code](https://bitwarden.com/help/two-step-recovery-code/) - [Bitwarden — Emergency Access](https://bitwarden.com/help/emergency-access/)