> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CLERK AUTH ONBOARDING ROUTING

# 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

| Layer | What | When active |
| - | - | - |
| **Layer 1** | Supabase `all_users` read in middleware | Always |
| **Layer 2** | Clerk session-token claim (`sessionClaims.metadata`) | Only after the Clerk Dashboard session-token customization is enabled |

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**:

```json theme={null}
{
  "metadata": "{{user.public_metadata}}"
}
```

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`:

```ts theme={null}
declare global {
  interface CustomJwtSessionClaims {
    metadata: {
      onboarding_completed?: boolean
      account_deleted?: boolean
    }
  }
}
```

## 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:

| Field | Actionable transition | Common value | Strategy |
| - | - | - | - |
| `onboarding_completed` | `false → true` | `true` (most users) | **Trust `true`, verify `false`.** Only the small mid-onboarding cohort hits Supabase. Hot path preserved. |
| `account_deleted` | `false → true` | `false` (≈ everyone) | **Cannot** verify `false` in the hot path — that's a DB read for every user on every request. Enforced on the **write path** instead (see below). |

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:

```ts theme={null}
if (first_name) { /* encrypt + update */ }
if (last_name)  { /* encrypt + update */ }
```

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

* [Clerk: Add custom onboarding to your authentication flow](https://clerk.com/docs/guides/development/add-onboarding-flow)
* [Clerk: Force a token refresh (`user.reload()`)](https://clerk.com/docs/guides/sessions/force-token-refresh)
* [Clerk: `updateUser` (replaces metadata)](https://clerk.com/docs/reference/backend/user/update-user)
* [Clerk: `updateUserMetadata` (deep-merges metadata)](https://clerk.com/docs/reference/backend/user/update-user-metadata)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.