# Pack: identity, auth, tenancy

Load when: the touch list exhibits jose (`jwtVerify(`), Better Auth (`betterAuth(`, `auth.api.`), session cookies, CSRF middleware, OAuth callbacks/`redirect_uri`, tenant scoping (`tenantId`, `organizationId`), password hashing, or auth middleware mounts. This area skews CRITICAL: the defining hazard is a silent auth bypass or data disclosure, not a loud error.

## jose-jwtverify-claims-opt-in — CRITICAL · trust-invariant
**Contract:** The token minter scopes tokens with `iss`/`aud`, and every verifier must pass matching `issuer`/`audience` options to `jwtVerify` — jose 6 checks signature, `exp`, `nbf` by default but NOT `iss`, `aud`, `sub`, or `typ` unless the option is passed.
**Detect:** `jwtVerify(` without `issuer:`/`audience:` in the same options object, `jwtVerify(token, secret)` two-arg calls, missing `algorithms:` where the key is an HMAC secret (`Uint8Array`), `clockTolerance`, `maxTokenAge`
**Ships green, breaks:** A valid token minted for a *different* service or audience by the same issuer/key verifies successfully — cross-service token replay; with a shared HMAC secret and no `algorithms: ['HS256']` allowlist, any HS* alg the secret supports is accepted. Everything compiles; the token "verifies".
**Safe change:** Always pass `{ issuer, audience, algorithms }` to `jwtVerify`; treat a bare two-arg call as a finding. Add `clockTolerance: '30s'` for cross-host skew. When adding a new consumer service, mint tokens with a distinct `aud` and enforce it there.

## jwt-identity-keyed-on-iss-plus-sub — CRITICAL · trust-invariant
**Contract:** `sub` is unique only *per issuer* (RFC 7519); application user lookup must key on the pair `(iss, sub)`, never `sub` alone.
**Detect:** `payload.sub` used as a sole DB lookup key, multi-IdP config (multiple `issuer` values, per-tenant IdPs, `createRemoteJWKSet` called with more than one URL)
**Ships green, breaks:** With two identity providers (or per-tenant issuers), IdP B can mint `sub` values colliding with IdP A's users — a tenant-controlled IdP issues `sub: <victim-sub>` and logs in as the victim. Single-IdP tests never expose it.
**Safe change:** Store external identity as a composite `(issuer, subject)` unique key (mirror of Better Auth's `(providerId, accountId)` on `account`); grep every login path for `sub`-only lookups; adding a second IdP is the trigger event for this audit.

## jose-remote-jwks-rotation-cache — HIGH · lifecycle-protocol
**Contract:** The IdP's key-rotation schedule and every verifier's `createRemoteJWKSet` cache must follow the publish-first protocol: jose 6 re-fetches the JWKS when a token's `kid` matches no cached key, but at most once per `cooldownDuration` (default 30000 ms), and refreshes at most every `cacheMaxAge` (default 600000 ms = 10 min).
**Detect:** `createRemoteJWKSet(`, `cooldownDuration`, `cacheMaxAge`, IdP rotation jobs, `JWKSNoMatchingKey`, `ERR_JWKS_NO_MATCHING_KEY`
**Ships green, breaks:** IdP starts signing with a new key *before* publishing it → every verifier throws `JWKSNoMatchingKey`, and the 30 s cooldown suppresses re-fetch so the outage comes in waves; removing the old key from the JWKS while its tokens are still live mass-invalidates sessions; each process instance caches independently, so rollouts fail partially.
**Safe change:** Rotate as: publish new key to JWKS → wait > `cacheMaxAge` → start signing with new `kid` → keep old key published until the longest-lived old token expires; never sign-then-publish; keep `kid` on every JWK and JWS header.

## better-auth-schema-dual-authority — CRITICAL · dual-authority
**Contract:** Better Auth's CLI (`npx @better-auth/cli generate` / `migrate`) owns the shape of the `user`, `session`, `account`, `verification` tables (camelCase columns: `session.expiresAt` — not `expires` — `session.token`, `account.providerId`, `account.accountId`); your ORM migration tool is a second writer over the same tables.
**Detect:** `@better-auth/cli`, `drizzle-kit push`, `drizzle-kit generate`, auth tables present in DB but absent from `schema.ts`, `modelName`/`fields` remapping in `betterAuth(` config
**Ships green, breaks:** `drizzle-kit push` diffing against a schema file that lacks the auth tables will *drop them* (with `--force`/CI, no prompt) — every user, session, and OAuth link destroyed; adding a Better Auth plugin (admin, organization, twoFactor) without re-running `generate`/`migrate` leaves its columns (`impersonatedBy`, `role`, `banned`, `activeOrganizationId`) missing, so only that plugin's runtime paths error while core login still works.
**Safe change:** After any `betterAuth(` config or plugin change, re-run `npx @better-auth/cli generate` and diff into the ORM schema; keep auth tables in the ORM schema file so push never sees them as deleted; column renames only via `fields`/`modelName` config, never raw SQL.

## better-auth-session-token-not-a-jwt — HIGH · serialized-shape
**Contract:** The `better-auth.session_token` cookie carries an opaque signed token (`<token>.<hmac>`) whose first half is the `session.token` DB row key — it is NOT a JWT access/refresh pair, and every other service must validate it by calling `auth.api.getSession({ headers })`, not by decoding.
**Detect:** `jwtVerify`/`decodeJwt`/`atob(` applied to the session cookie, `session.cookieCache`, `session_data`, `getSessionCookie(`
**Ships green, breaks:** A sibling service that "verifies" the cookie as a JWT fails for all users; with `session.cookieCache: { enabled: true, maxAge }` the session payload is served from the signed `session_data` cookie, so `revokeSession`/user-ban does NOT take effect until the cookie cache expires — logout/ban tests without cookieCache pass, production revocation lags. `getSessionCookie()` only checks cookie *existence*, never validity — using it as an auth gate is a bypass.
**Safe change:** Cross-service auth goes through `getSession` against the auth service (or add the `jwt` plugin and verify those JWTs with jose); if enabling `cookieCache`, bound `maxAge` by your revocation SLA; treat `getSessionCookie` as an optimistic-redirect hint only.

## better-auth-cookie-name-rendezvous — HIGH · rendezvous-string
**Contract:** Every consumer that reads the session cookie by name (edge middleware, nginx, a second service) must compute the same name Better Auth writes: `{cookiePrefix}.session_token` (default prefix `better-auth`), which becomes `__Secure-better-auth.session_token` when cookies are secure (production / `advanced.useSecureCookies: true`).
**Detect:** literal `better-auth.session_token`, `__Secure-`, `advanced.cookiePrefix`, `advanced.crossSubDomainCookies`, `getSessionCookie(request, { cookieName`
**Ships green, breaks:** Code reading `cookies['better-auth.session_token']` works in dev (http, no prefix) and silently sees no cookie in production because the name is `__Secure-`-prefixed — everyone appears logged out on the consumer side only; changing `cookiePrefix` orphans every hardcoded reader. `crossSubDomainCookies` is **disabled by default** — a sibling subdomain service never receives the cookie until `advanced.crossSubDomainCookies: { enabled: true, domain }` is set.
**Safe change:** Never hardcode the cookie name — use `getSessionCookie(request, { cookieName, cookiePrefix })` with the same config as the server; when changing `cookiePrefix`/`crossSubDomainCookies`, grep all services for the old literal; test cookie reads under https/production mode, not just dev.

## better-auth-trusted-origins-allowlist — CRITICAL · config-elsewhere
**Contract:** `trustedOrigins` (default: only the `baseURL` origin) is the single allowlist gating both the Origin-header CSRF check and every client-supplied `callbackURL`/redirect target.
**Detect:** `trustedOrigins`, wildcard entries `https://*.` / `**`, `callbackURL` passed from client code, new frontend domains not present in the auth config
**Ships green, breaks:** The loud half: a new frontend origin fails with 403s. The silent half: someone "fixes" it with `https://*.example.com` — now any subdomain (stale CNAME takeover, user-content subdomains, preview deploys) can drive login CSRF and receive `callbackURL` redirects carrying fresh sessions; `**` additionally crosses `/`. The wildcard ships green because every legitimate flow still works.
**Safe change:** Enumerate origins exactly; if wildcards are unavoidable, scope to a dedicated non-user-controllable parent domain; review `trustedOrigins` whenever a domain is added/retired.

## session-cookie-attribute-downgrade — CRITICAL · trust-invariant
**Contract:** The session cookie's `HttpOnly` + `Secure` + `SameSite=Lax|Strict` + narrowest-possible `Domain` collectively are the CSRF/theft defense; any relaxation keeps login working while removing the guarantee.
**Detect:** `sameSite: 'none'`, `httpOnly: false`, `domain: '.` (parent-domain widening), `secure: false`, proxy configs rewriting `Set-Cookie`
**Ships green, breaks:** Switching to `SameSite=None` (to make an iframe work) silently re-enables classic CSRF on every state-changing endpoint unless a token defense exists; widening `Domain` to `.example.com` hands the session to every subdomain including compromised/user-content ones; `httpOnly: false` (added so JS can check "am I logged in") turns any XSS into full session theft. All ship green — the cookie still authenticates every legitimate request.
**Safe change:** Treat any diff touching cookie attributes as security review; `SameSite=None` requires a compensating CSRF token on the same PR; keep `Domain` host-only unless cross-subdomain is a deliberate product decision; expose login state via an endpoint, never by de-HttpOnly-ing the session cookie.

## csrf-double-submit-lockstep — HIGH · rendezvous-string
**Contract:** In double-submit CSRF, the CSRF cookie must be JS-readable (NOT HttpOnly) while the session cookie must be HttpOnly, and the frontend's header name must match the backend's reader — axios defaults `xsrfCookieName: 'XSRF-TOKEN'` / `xsrfHeaderName: 'X-XSRF-TOKEN'`.
**Detect:** `XSRF-TOKEN`, `X-XSRF-TOKEN`, `xsrfCookieName`, `xsrfHeaderName`, `withXSRFToken`, CSRF middleware with a skip-when-absent branch, blanket `httpOnly: true` cookie sweeps
**Ships green, breaks:** The two cookies are routinely inverted: a "hardening" sweep sets HttpOnly on everything — the frontend can't read the CSRF cookie, and if the middleware validates only *when the header is present*, requests sail through with zero protection, silently; renaming the header on one side has the same fail-open effect. axios only auto-attaches the XSRF header same-origin unless `withXSRFToken` is set — cross-origin SPAs silently send no token.
**Safe change:** CSRF middleware must *reject* state-changing requests when the token is absent, never skip; pin cookie+header names in one shared constant consumed by both sides; assert in an integration test that a request without the header is refused.

## oauth-redirect-uri-exact-match — CRITICAL · trust-invariant
**Contract:** The OAuth authorization-server allowlist and the client's `redirect_uri` agree by *exact string match*; `state` (and `nonce`/PKCE for OIDC) must be both generated and verified — the flow completes identically when verification is dropped.
**Detect:** `redirect_uri`, `startsWith(`/prefix/wildcard matching in redirect validation, `genericOAuth` plugin configs, hand-rolled `/callback` handlers, `state` generated but never compared, `code_verifier`/`code_challenge` present on only one leg
**Ships green, breaks:** Prefix or wildcard matching lets `https://app.example.com.evil.com` or a path-traversal callback receive the authorization code — code exfiltration, full account access; dropping the `state` comparison (or the PKCE `code_verifier` check) fails open: every legitimate login still works, but login-CSRF/code-injection is silently enabled. Better Auth's built-in providers handle this — the hazard is `genericOAuth` configs and hand-rolled flows.
**Safe change:** Register and compare `redirect_uri` byte-for-byte (scheme, host, port, path); generate `state` bound to the session and *reject* on mismatch or absence; require PKCE S256 end-to-end; test the negative paths (tampered state, missing verifier), not just the happy path.

## account-linking-verified-email-only — CRITICAL · trust-invariant
**Contract:** External identities key on `(providerId, accountId)` (Better Auth's `account` table), and auto-linking a new OAuth identity to an existing user by email is safe only when the provider attests the email as verified — Better Auth's default links only on verified-email match; `trustedProviders` bypasses that check and `allowDifferentEmails: true` (default `false`) widens it further.
**Detect:** `account.accountLinking`, `trustedProviders`, `allowDifferentEmails`, custom provisioning doing `findUserByEmail` on OAuth callback, lookups keyed on profile email instead of `(providerId, accountId)`
**Ships green, breaks:** Adding a provider to `trustedProviders` (or hand-rolling link-by-email) means an attacker registers at that provider with the victim's email *unverified*, signs in, and is silently linked into the victim's account — full takeover; every legitimate login flow still works, so nothing surfaces in testing.
**Safe change:** Keep identity lookup on `(providerId, accountId)`; only list providers in `trustedProviders` that contractually verify emails; leave `allowDifferentEmails` false unless linking is an explicit user-initiated, authenticated action.

## tenant-id-non-spoofable-source — CRITICAL · trust-invariant
**Contract:** The tenant identifier used to scope every query must come from a server-validated source — a verified JWT claim, the session (e.g. Better Auth organization plugin's `session.activeOrganizationId`), or server-resolved subdomain — never from `req.body`, `req.query`, or a client-set header; and every data access must actually include the scope.
**Detect:** `req.body.tenantId`, `req.query.tenantId`, `req.headers['x-tenant`, queries by bare PK in multi-tenant repos (`WHERE id = ?` without `tenant_id`/`organizationId`), new route files not using the shared tenant middleware
**Ships green, breaks:** Two failure modes, both silent: (1) tenant read from the request → any authenticated user sets another tenant's id and reads/writes their data; (2) the new-endpoint failure — a handler queries by UUID primary key alone, works perfectly in every test (ids are globally unique), and is a cross-tenant IDOR in production. Neither throws, lints, or fails a test.
**Safe change:** One `requireTenant` middleware derives `req.tenantId` from session/claim only — delete all request-sourced fallbacks; make the repository layer take `tenantId` as a required parameter so unscoped queries can't be expressed; review every new endpoint for the scope, not just the auth check.

## express5-auth-middleware-mount-order — HIGH · lifecycle-protocol
**Contract:** In Express 5, `app.use(authMiddleware)` protects only routes and routers mounted *after* it in registration order, and per-identity rate limiting (`keyGenerator: req => req.user.id`) requires auth to have already run.
**Detect:** `app.use(` ordering in the composition root, router mounts above the auth `app.use`, `keyGenerator` referencing `req.user`, `app.use('*'` patterns (path-to-regexp v8: bare `*` is invalid — use `/*splat`)
**Ships green, breaks:** A new `app.use('/api/reports', reportsRouter)` line added above the auth mount ships green — every endpoint works in manual testing (the dev is logged in anyway) — but is fully unauthenticated; a rate limiter mounted before auth with an identity `keyGenerator` sees `req.user === undefined`, collapsing all clients into one shared `undefined` bucket: global lockout under load and per-user limits silently gone.
**Safe change:** Keep one composition root with the invariant order: parsers → auth → identity-keyed rate limit → routers → error handler; mount new routers only below the auth line; give identity-keyed limiters a hard failure (throw) when `req.user` is absent instead of a fallback key.

## password-hash-format-migration — CRITICAL · serialized-shape
**Contract:** Stored password hashes (Better Auth default: scrypt, in `account.password` for `providerId: 'credential'`) are a persisted serialized format that every future `verify` implementation must still accept.
**Detect:** `emailAndPassword.password: { hash, verify }` overrides, argon2/bcrypt param changes, hash-format prefixes in `account.password` vs what `verify` parses, absence of rehash-on-login logic
**Ships green, breaks:** Swapping the algorithm (scrypt → argon2) or tightening params without a legacy path makes `verify` return `false` for every pre-existing user — no error is thrown, new signups work, fresh-user tests pass, and the user base is silently locked out over the following weeks as sessions expire.
**Safe change:** New `verify` must detect the old format by prefix and accept it; on successful legacy verify, rehash with the new params and update the row; keep the legacy branch until no old-format hashes remain (measure, don't guess); never bulk-invalidate.

## impersonation-dual-identity-audit — HIGH · trust-invariant
**Contract:** With Better Auth's admin plugin, an impersonated session carries BOTH identities — `session.userId` is the target, `session.impersonatedBy` is the acting admin — and every audit/attribution consumer must read both; the impersonated session auto-expires after 1 hour by default (`impersonationSessionDuration`, seconds).
**Detect:** `impersonateUser`, `stopImpersonating`, `impersonatedBy`, `impersonationSessionDuration`, audit code reading only `session.userId`, session payloads forwarded downstream without `impersonatedBy`
**Ships green, breaks:** Audit logs, "last modified by" fields, and downstream services that record `session.userId` silently attribute admin actions to the *victim* user — the audit trail is wrong precisely when it matters; custom session serialization that drops `impersonatedBy` erases the distinction entirely. Impersonation itself works flawlessly, so nothing surfaces.
**Safe change:** Every audit write records `actorId = session.impersonatedBy ?? session.userId` plus `onBehalfOf` when they differ; propagate `impersonatedBy` in any session payload crossing a service boundary; after adding the admin plugin, re-run the Better Auth CLI so the `impersonatedBy` column exists (see schema dual-authority entry).
