Clerk Auth & Onboarding Routing (CFO-5992)
How signed-in users are routed between/, /onboarding, /dashboard, and
/account-deleted, why the system is built to be loop-proof, and the
invariants you must preserve if you extend it.
Originating issue: logged-in users got silently stuck in a redirect loop on the landing page. The root cause and the design that fixes it are below.
TL;DR
- The middleware is the single routing chokepoint for signed-in users.
- State (
onboarding_completed,account_deleted) is resolved from two sources, in priority order: the Clerk session-token claim (DB-free hot path) and a Supabaseall_usersread (source of truth / fallback). - Supabase is always the source of truth. Clerk
public_metadatais a mirror used only to make the hot path DB-free. - Two governing rules prevent loops:
- Never redirect a verified signed-in user BACKWARD to
/. Verified-user redirects go FORWARD only. - Trust a claim only for the “completed” value; verify the actionable pre-transition value against Supabase.
- Never redirect a verified signed-in user BACKWARD to
The two-layer model
Until Layer 2 is enabled,
sessionClaims.metadata is undefined and the
middleware always falls through to the Supabase read. Layer 2 is what makes the
common case DB-free.
Enabling Layer 2 (Clerk Dashboard)
Dashboard → Sessions → Customize session token → Claims editor:public_metadata object, so every key (onboarding_completed,
account_deleted, and any future key) flows into the metadata claim with no
further editor changes. The session token is capped at 1.2 KB — our metadata
is two booleans, so the whole-object mapping is safe. Do not pour large or
sensitive blobs into public_metadata, or switch to mapping individual fields.
Type contract lives in types/global.d.ts:
State resolution (middleware.ts)
For a verifieduserId:
- Claim, completed-only: if
claimMetadata?.onboarding_completed === true, trust it (and readaccount_deletedfrom the claim). No DB read. - Otherwise (claim absent, non-boolean, or
false): read Supabaseall_userswith one retry.- row found → use the row
- row
null→onboarding_completed = false(brand-new user whoseuser.createdwebhook insert hasn’t landed; route to/onboarding, which self-heals once the row exists) - both attempts throw → state UNKNOWN → fail-open FORWARD (see trade-off)
account_deleted === true→ redirect to/account-deletedonboarding_completed === false→ gate behind/onboarding(allow/onboarding; redirect protected routes and/; pass everything else)onboarding_completed === true→ push/and/onboardingFORWARD to/dashboard; let protected routes through- UNKNOWN → fail-open FORWARD: admit protected routes, push
/→/dashboard, never redirect to/
The asymmetry principle (why we trust true but verify false)
A stale-but-present claim is the loop’s re-entry door. The fix is to consult
Supabase only for the value that can be dangerously stale, and the safe
direction depends on the field’s population distribution:
This is the key subtlety: the “verify the pre-transition value” trick only stays
cheap when the pre-transition value is the rare one. For
account_deleted
it’s the common one, so the middleware trusts the claim and correctness is
guaranteed at the write site.
Write-path invariants (keep Clerk and Supabase in sync)
Because the middleware reads the Clerk claim on the hot path, any write that flips state must keeppublic_metadata and Supabase from diverging.
Onboarding completion — app/api/onboarding/save-user-info/route.ts
- Write Supabase first (source of truth).
- Then a single atomic
clerk.users.updateUser(clerkId, { firstName, lastName, publicMetadata: { ...clerkUser.publicMetadata, onboarding_completed: true } }).- One call → one
user.updatedwebhook. Do not split names and metadata into two calls — that emits two webhook snapshots, and for email signups the metadata-only snapshot carriesfirst_name/last_name = null(see clobber note below). updateUserreplacespublic_metadata(onlyupdateUserMetadatadeep merges). Always spreadclerkUser.publicMetadatasoaccount_deletedis preserved.- Non-fatal: Supabase is the source of truth and the middleware re-verifies a
non-completed claim against it. The client still calls
user.reload()to refresh the token, but routing is correct even if reload fails.
- One call → one
Connect-account flow (post-ship of add-account-onboarding-flow)
Status (2026-06-30): theTwo additional writes participate in onboarding state for new-signup users:add-account-onboarding-flowexperiment shipped thetestarm to 100% of users. The PostHog flag itself remains in place as a kill switch. The cleanup of this code path was deliberately split into two PRs:Until PR #2 lands, the plumbing described below is still load-bearing — it is what lets the middleware tell a new-signup (routed to
- PR #1 (done): Remove the PostHog flag dependency.
/auth/callbacknow hard-codes'test'for every eligible new signup. Tests covering thecontrolvariant and the flag-resolution timeout were removed.- PR #2 (follow-up): Retire the
connect_account_flowclaim, cookie, andflow-assignmentAPI route in favour of an explicit “new free signup needs /connect-account” signal that does not require an experiment-shaped sticky-assignment. This PR is deferred until the post-ship monitoring window confirms no regression and the small in-flight control-arm cohort has cleared./connect-account) apart from a paid Stripe-handoff signup (which must stay on/onboardingto preserve the checkout handoff).
- Assignment —
app/api/connect-account/flow-assignment/route.tspersists the assignment to Clerkpublic_metadata.connect_account_flow(always'test'post-ship; the API still accepts'control'so in-flight users with that bucket are unaffected), written once from/auth/callbackviaupdateUserMetadata(deep merge; never overwrites an existing assignment, so the bucket is sticky). The{{user.public_metadata}}session-token mapping carries it intosessionClaims.metadata.connect_account_flow(type intypes/global.d.ts) with no claims-editor change. The middleware prefers the claim whenever it carries the assignment; theconnect_account_flowcookie — whose value is bound to the userId (<id>:<arm>), so another user’s assignment on a shared browser is ignored — only decides during the window before the freshly-minted signup token refreshes (~60s). This keeps the assignment per-user, sticky across devices/cookie clears, and immune to Safari’s 7-day JS-cookie cap. Per the asymmetry table this flag needs no Supabase verification: both values are routing-equivalent in safety (worst case is seeing the other route’s onboarding UI, never lost access), so claim-or-cookie resolution is acceptable. - Deferred completion — the connect-account personal-info step calls
save-user-infowithcompleteOnboarding: false(names persist, the completion flags do NOT flip, and the Clerk write is names-only sopublic_metadatais untouched).onboarding_completedflips only at the end of the flow (Done / confirmed skip) viaapp/api/connect-account/complete-onboarding/route.ts, which follows the same write order assave-user-info: Supabase first (failure = 500, client retries), then a non-fatal Clerk metadata mirror. Until then the user stays middleware-gated to/connect-account, mirroring how control users cannot leave/onboardingearly.
Account deletion — app/api/account/delete/route.ts
- Confirm the Supabase soft-delete committed (
.select()+ row check) before mirroring to Clerk. - The Clerk
account_deleted: truewrite is CRITICAL, not best-effort: retried (3 attempts, backoff) and on persistent failure the route returns 500 so the client surfaces the error and retries. The Supabase soft-delete is idempotent onclerk_id, so retry is safe.- Why critical: the middleware reads
account_deletedfrom the claim for completed users and (per the asymmetry above) cannot cheaply re-verify afalseclaim. A silent mirror failure would leave a deleted user with access indefinitely once Layer 2 is on.
- Why critical: the middleware reads
user.created webhook — app/api/webhooks/clerk/route.ts
- Insert
all_usersfirst; only mirrorpublic_metadataafter a confirmed insert. A non-23505insert error returns 500 so Svix retries (the metadata mirror is skipped so the claim never gets ahead of the source-of-truth row). - The metadata mirror runs only on a genuine fresh insert (
!insertError). On the23505(row-already-exists) retry path the mirror is skipped: re-mirroring with the original event payload would clobber neweronboarding_completed/account_deletedstate written by downstream owners (the loop / deleted-account-resurrection door). If the original mirror never landed, the claim is simply absent and the Supabase fallback resolves routing until a downstream write sets it.
Webhook name-clobber guard (user.updated)
Clerk emits a user.updated snapshot for any user mutation, including
public_metadata writes. For email signups, that snapshot has
first_name/last_name = null until onboarding sets them. The handler therefore
only writes names when present — it never persists a null/empty name:
user.created
metadata-mirror event, or a Svix retry delivered out of order) permanently
wiping real names. Safe because the app never legitimately clears a name —
user-info/update and onboarding both require non-empty names.
Known trade-offs (deliberate)
account_deletedis eventually-consistent. Clerk keeps issuing JWTs after a soft delete (we don’t revoke the session), and a stalefalseclaim admits a deleted user until the token refreshes (~60s). This is accepted; the write-path retry/500 closes the indefinite divergence, not the ~60s window.- UNKNOWN fail-open admits a soft-deleted user during a Supabase outage. The
account_deletedcheck is a positive-truth test that doesn’t fire onundefined. Bounded to the outage window. We do NOT tighten this to redirect UNKNOWN →/account-deletedbecause that would mis-route every signed-in user during an outage. The single Supabase retry shrinks this window. - Duplicate
deleted_accountsrow on delete retry. If the Clerk write fails persistently and the client retries the whole POST, a second deletion-reason row is inserted (Plaid teardown and theall_usersflip are idempotent). Low-harm, rare path; left as-is.
Observability (PostHog)
- Server:
capturePostHogExceptionon every write-path failure (onboarding_save_user_info_clerk_update,account_delete_metadata,account_delete_soft_delete,clerk_webhook_user_created_insert,clerk_webhook_user_created_metadata). - Client:
auth_callback_token_reload_error,onboarding_token_reload_error. authenticated_user_on_landingfires if a signed-in user is still on/after 500ms — the canary for a stuck/loop state. Pair it with the$pageviewon/dashboardto measure signed-in → dashboard conversion vs. stuck drop.
Extending this safely — checklist
- Adding a new gating flag in
public_metadata?- Decide its actionable transition and which value is common, then apply the asymmetry principle (trust the rare-but-final value; enforce the common value at the write site or via Supabase only if it stays cheap).
- Add it to
CustomJwtSessionClaimsintypes/global.d.ts. {{user.public_metadata}}already carries it into the token — no editor change needed.
- Adding a new protected route? Add it to
PROTECTED_ROUTESinmiddleware.ts. - Never redirect a verified signed-in user to
/. Forward-only. - Never write
public_metadatawithupdateUserwithout spreading the existing metadata (it replaces, not merges). - Keep state-flipping Clerk writes to a single call to avoid extra
user.updatedwebhooks.
Key files
middleware.ts— routing chokepoint + state resolutiontypes/global.d.ts—CustomJwtSessionClaimsapp/api/onboarding/save-user-info/route.ts— onboarding completion writeapp/api/account/delete/route.ts— soft-delete + critical metadata mirrorapp/api/webhooks/clerk/route.ts—user.createdinsert/mirror,user.updatedname-clobber guardapp/(app)/onboarding/page.tsx—finishOnboarding(user.reload()+ forward)app/(public)/auth/callback/page.tsx— post-auth routing + token refresh