All pages

Identity

Clerk owns identity. This app keeps a users row so that purchases and subscriptions have something to hang off, and so the api never has to ask Clerk who someone is on a hot path.

The row is a mirror, not a source of truth. It carries a Clerk user id, an email and a display name, all three re-fetched from Clerk whenever they are written. There is no password column, no is_admin column and no plan column, and each of those absences is load-bearing rather than incidental.

How a request becomes a user

Every guarded router in the api stacks the same two middlewares as its first statement, and they answer differently on purpose.

Middleware Question Failure
authenticateToken is there a usable Clerk session on this request 401 unauthenticated
requireUser does that session resolve to a users row 403 no_user_context

authenticateToken verifies and writes nothing. requireUser resolves the Clerk id to a row and is the only writer of res.locals.user, which is where every handler reads the caller from. Not a path parameter, not a query string, not the request body.

That last point is enforced rather than documented. apps/api/biome.json makes req.user, request.user and body.user lint errors, because the code this replaces carried caller identity in req.body.user, which fabricates a body on GET routes and is spoofable on any route the middleware does not wrap.

// The whole of how a handler learns who is calling.
export const getEntitlements: RequestHandler = async (_req, res) => {
  const body = await readEntitlements(res.locals.user.id)

  res.json(body)
}

res.locals.user is typed as present rather than optional in apps/api/src/types/express.d.ts. That claim holds because a router's baseline guard is its first statement and later .use() calls may only add guards, so a route appended to the bottom of a guarded file lands inside the guard. A genuinely public route gets its own router instead of a hole punched in a guarded one.

The row is created on first sight

requireUser selects, and inserts only on a miss. The steady state is one read per authenticated request, and the first request from a new account is a read plus an upsert.

This matters because the alternative is what the reference implementation did: rows arrived only from Clerk's user.created webhook, and a request from an account the database had not heard of answered 404. That conflates "not signed in" with "not provisioned", so a client cannot tell which it is looking at, and it makes the app unusable locally, because without a webhook tunnel running the first page after signing up is broken forever.

Creating the row lazily is what lets CLERK_WEBHOOK_SECRET sit in the optional tier. The webhook keeps the mirror fresh; it is not what brings it into existence.

Both writers go through one function, upsertUserFromClerk, which takes an id and re-fetches. The insert carries onConflictDoUpdate on the clerk_user_id unique index, because two concurrent first requests both miss the select and both insert, and without the conflict clause the loser is a unique violation that reaches a buyer as a 500 on the first page load of their own product.

Signed in or not is the only thing the web app decides

apps/web/src/start.ts runs clerkMiddleware() and a request policy for every request. The policy calls Clerk's server-side auth() for private HTML document paths and redirects a signed-out caller to /auth/login before rendering the page.

Private routes also call the requireAuth server function from their beforeLoad hook. That check covers client navigation, where a new HTML document request never reaches the global request policy.

The route list is an allowlist, and everything absent from it requires a session:

const PUBLIC_PATHS = new Set([
  '/', '/about', '/updates', '/thank-you',
  '/license-agreement', '/privacy-policy', '/terms-conditions',
  '/llms.txt', '/llms-full.txt', '/sitemap.xml', '/robots.txt',
])

const PUBLIC_PREFIXES = ['/auth', '/blog', '/docs', '/__clerk']

The direction is the point. A marketing page somebody forgets to add asks for a sign-in, which is annoying and gets reported. A dashboard route somebody forgets to add under a default-allow scheme is a leak, and nobody reports those.

Prefixes match the prefix itself or the prefix followed by /, never a bare startsWith, so a future /blog-admin does not inherit /blog's public status.

/__clerk remains in the public prefix matcher, but this repository does not enable a Clerk frontend API proxy. There is no frontendApiProxy or proxyUrl configuration.

Anything finer-grained than signed in or not belongs to the api. See Entitlements for why a client-side entitlement check is a loading state.

No passwords

Identity is an email code plus the Google and GitHub providers. No user of this product has a password, so there is no route that sets, verifies, forgets or resets one, and no session can ever carry Clerk's reset-password task.

GitHub is one more sign-in option and nothing else reads the GitHub identity. docs/adr/0021-no-passwords-email-code-and-oauth.md records why the providers were chosen, and docs/adr/0031-identity-precedes-payment.md why sign-in comes before payment.

The sign-in and sign-up screens are this repo's own components on Clerk's headless Signal API, rather than Clerk's hosted pages or its prebuilt <SignIn />. That decision and its maintenance cost are in docs/adr/0022-own-auth-ui-on-headless-clerk.md.

The cases that bite

A session with a pending task reads as signed out. Clerk treats a session carrying an unfinished task as signed out everywhere, including getAuth() in the api, and getAuth defaults treatPendingAsSignedOut to true. setup-mfa is on by default in a new Clerk application, so a reader who has a session but has not finished it is a 401 at the api while their browser believes it signed them in.

The symptom is signed in but sees nothing, with no error and nothing in a log. The fix is taskUrls on <ClerkProvider>, which is set in apps/web/src/routes/__root.tsx and pushes them through the task. It is not a looser default in the api.

Deleting a Clerk account keeps the payment records. The user.deleted webhook first cancels every Stripe subscription that can still charge the user's Customer, because a deleted account has no way into the billing portal. It then deletes the users row. purchases.user_id and subscriptions.user_id are both onDelete: 'set null', so the payment rows stay, with their email and Stripe ids, as unowned history that grants no Entitlement. Bookkeeping rules usually require you to keep those records for years after the account is gone.

user.deleted is the one event whose payload is written rather than re-fetched. The object it describes is gone, so there is nothing left to look up. Every other Clerk and Stripe event in this repo is a trigger, which is Webhooks.

Admin is not identity

An admin is a Clerk user id listed in ADMIN_CLERK_IDS on the api. There is no column, no table and no UI, and therefore no path by which an admin grants admin. An empty allowlist means this deployment has no admins, which is the state a fresh clone is in and a valid one to stay in.

requireAdmin runs no query. It compares the id getAuth(req) already verified against the allowlist, so the check costs nothing and the row is already on res.locals.user from requireUser.

Updated