Skip to main content

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 Supabase all_users read (source of truth / fallback).
  • Supabase is always the source of truth. Clerk public_metadata is a mirror used only to make the hot path DB-free.
  • Two governing rules prevent loops:
    1. Never redirect a verified signed-in user BACKWARD to /. Verified-user redirects go FORWARD only.
    2. Trust a claim only for the “completed” value; verify the actionable pre-transition value against Supabase.

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:
This maps the entire 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 verified userId:
  1. Claim, completed-only: if claimMetadata?.onboarding_completed === true, trust it (and read account_deleted from the claim). No DB read.
  2. Otherwise (claim absent, non-boolean, or false): read Supabase all_users with one retry.
    • row found → use the row
    • row null → onboarding_completed = false (brand-new user whose user.created webhook 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)
Then route:
  • account_deleted === true → redirect to /account-deleted
  • onboarding_completed === false → gate behind /onboarding (allow /onboarding; redirect protected routes and /; pass everything else)
  • onboarding_completed === true → push / and /onboarding FORWARD 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 keep public_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.updated webhook. Do not split names and metadata into two calls — that emits two webhook snapshots, and for email signups the metadata-only snapshot carries first_name/last_name = null (see clobber note below).
    • updateUser replaces public_metadata (only updateUserMetadata deep merges). Always spread clerkUser.publicMetadata so account_deleted is 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.

Connect-account flow (post-ship of add-account-onboarding-flow)

Status (2026-06-30): the add-account-onboarding-flow experiment shipped the test arm 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:
  1. PR #1 (done): Remove the PostHog flag dependency. /auth/callback now hard-codes 'test' for every eligible new signup. Tests covering the control variant and the flag-resolution timeout were removed.
  2. PR #2 (follow-up): Retire the connect_account_flow claim, cookie, and flow-assignment API 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.
Until PR #2 lands, the plumbing described below is still load-bearing — it is what lets the middleware tell a new-signup (routed to /connect-account) apart from a paid Stripe-handoff signup (which must stay on /onboarding to preserve the checkout handoff).
Two additional writes participate in onboarding state for new-signup users:
  • Assignment — app/api/connect-account/flow-assignment/route.ts persists the assignment to Clerk public_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/callback via updateUserMetadata (deep merge; never overwrites an existing assignment, so the bucket is sticky). The {{user.public_metadata}} session-token mapping carries it into sessionClaims.metadata.connect_account_flow (type in types/global.d.ts) with no claims-editor change. The middleware prefers the claim whenever it carries the assignment; the connect_account_flow cookie — 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-info with completeOnboarding: false (names persist, the completion flags do NOT flip, and the Clerk write is names-only so public_metadata is untouched). onboarding_completed flips only at the end of the flow (Done / confirmed skip) via app/api/connect-account/complete-onboarding/route.ts, which follows the same write order as save-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 /onboarding early.

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: true write 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 on clerk_id, so retry is safe.
    • Why critical: the middleware reads account_deleted from the claim for completed users and (per the asymmetry above) cannot cheaply re-verify a false claim. A silent mirror failure would leave a deleted user with access indefinitely once Layer 2 is on.

user.created webhook — app/api/webhooks/clerk/route.ts

  • Insert all_users first; only mirror public_metadata after a confirmed insert. A non-23505 insert 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 the 23505 (row-already-exists) retry path the mirror is skipped: re-mirroring with the original event payload would clobber newer onboarding_completed/account_deleted state 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:
This defends against a stale null-name event (e.g. the 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_deleted is eventually-consistent. Clerk keeps issuing JWTs after a soft delete (we don’t revoke the session), and a stale false claim 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_deleted check is a positive-truth test that doesn’t fire on undefined. Bounded to the outage window. We do NOT tighten this to redirect UNKNOWN → /account-deleted because that would mis-route every signed-in user during an outage. The single Supabase retry shrinks this window.
  • Duplicate deleted_accounts row 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 the all_users flip are idempotent). Low-harm, rare path; left as-is.

Observability (PostHog)

  • Server: capturePostHogException on 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_landing fires if a signed-in user is still on / after 500ms — the canary for a stuck/loop state. Pair it with the $pageview on /dashboard to 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 CustomJwtSessionClaims in types/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_ROUTES in middleware.ts.
  • Never redirect a verified signed-in user to /. Forward-only.
  • Never write public_metadata with updateUser without spreading the existing metadata (it replaces, not merges).
  • Keep state-flipping Clerk writes to a single call to avoid extra user.updated webhooks.

Key files

  • middleware.ts — routing chokepoint + state resolution
  • types/global.d.ts — CustomJwtSessionClaims
  • app/api/onboarding/save-user-info/route.ts — onboarding completion write
  • app/api/account/delete/route.ts — soft-delete + critical metadata mirror
  • app/api/webhooks/clerk/route.ts — user.created insert/mirror, user.updated name-clobber guard
  • app/(app)/onboarding/page.tsx — finishOnboarding (user.reload() + forward)
  • app/(public)/auth/callback/page.tsx — post-auth routing + token refresh

References