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 <uid>@moviedice.invalid email + HKDF password promotion) is the system under reconsideration. Driving pain points: GoTrue #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).
Mechanism. First visit: browser calls signInAnonymously(); GoTrue mints a JWT with sub = <uid>, 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.
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; Supabase realtime + custom JWT discussion #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.
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.
Mechanism (better-auth specifically). Install better-auth with the anonymous plugin. 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 — 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, #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."
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.
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 = <row owner>). 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.
| 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 |
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 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.