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

# Session jwt rotation runbook

# Session JWT signing key: provisioning and rotation runbook

Owner: whoever holds Vercel + Convex production access. **Rotation permanently
involves a human `convex deploy` step** — `AGENTS.md` Safety prohibits agents from
running production deploys, so no part of this is automated.

Context: FM-2254 moved the `fm_session` JWT from HS256 (shared secret) to ES256
(P-256 key pair) so Convex's `customJwt` provider can verify it. Convex supports
only RS256 and ES256 and has no shared-secret mode.

## The pieces

| Where | Name | Value |
| - | - | - |
| Vercel (all envs) | `SESSION_JWT_PRIVATE_KEY` | base64 of the PKCS#8 PEM, signing key |
| Vercel (all envs) | `SESSION_JWT_PUBLIC_KEY` | base64 of the matching SPKI PEM |
| Vercel (all envs) | `SESSION_JWT_KID` | key id, e.g. `fm-2026-08` |
| Vercel, during overlap only | `SESSION_JWT_PREV_PUBLIC_KEY` | base64 SPKI PEM of the OUTGOING key |
| Vercel, during overlap only | `SESSION_JWT_PREV_KID` | key id of the outgoing key |
| Vercel (optional) | `SESSION_JWT_ISSUER` / `SESSION_JWT_AUDIENCE` | override the per-environment defaults |
| Convex deployment | `SESSION_JWKS_DATA_URI` | `data:text/plain;charset=utf-8;base64,<base64 of the JWKS>` |
| Convex deployment (optional) | `SESSION_JWT_ISSUER` / `SESSION_JWT_AUDIENCE` | must match the Next.js side EXACTLY |

None of these may ever gain a `NEXT_PUBLIC_` prefix. That prefix is what would put
the private key in the client bundle.

The PEMs are base64-wrapped on purpose: raw multi-line PEMs pasted into the Vercel
and Convex env UIs are a reliable source of newline-mangling bugs, and a
single-line value is safe to copy. The loader also accepts a raw PEM, so a value
pasted without the base64 step still works rather than failing in a way that looks
like a wrong key.

## Defaults if you set nothing

`lib/auth/sessionJwtConstants.ts` resolves:

* `iss` — `https://app.myfundingmachine.com` in production,
  `https://dev.myfundingmachine.com` everywhere else. "Production" means
  `VERCEL_ENV === "production"`, falling back to `NODE_ENV === "production"` when
  `VERCEL_ENV` is absent. `VERCEL_ENV` is checked first because Next sets
  `NODE_ENV=production` for PREVIEW builds too, and a preview using the production
  issuer would make its tokens replayable against production Convex.
* `aud` / `applicationID` — `fm-convex`.
* `jwks` — `SESSION_JWKS_DATA_URI` if set, otherwise the issuer-derived
  `https://<issuer host>/.well-known/jwks.json`.

`issuer` and `applicationID` in `convex/auth.config.ts` must match the token's
`iss` and `aud` **exactly**. A one-character disagreement produces no error
anywhere: `ctx.auth.getUserIdentity()` simply returns null for every subscription,
which looks identical to having no auth configured. `tests/convexAuthConfig.test.ts`
compares the config against a real minted token, but it cannot see a wrong value in
the Convex-side env var at deploy time. Only a human checking the deployed config
can.

## Verifying which mode is live

`GET /api/auth/status` returns a public-safe `convexIdentity` block:

```json theme={null}
{ "mode": "es256", "algorithm": "ES256", "kid": "fm-2026-08", "hasPreviousKey": false }
```

* `mode: "es256"` — healthy.
* `mode: "hs256-fallback"` — the key is missing or unusable. Tokens are still
  minted (dev, preview and CI must never be blocked on key provisioning) but
  Convex cannot verify them, so the browser Convex client stays unauthenticated.
  This mode is impossible in production; see below.
* `mode: "misconfigured"` — production with no usable key. Logins are failing.

The same state is logged once per process on the first mint. The route exposes
only the mode, the kid, and whether a previous key exists: it is proxy-exempt and
therefore world-readable, so it must never carry key material or reveal whether a
specific value is correct.

**Production fails closed; everything else falls back.** With no usable
`SESSION_JWT_PRIVATE_KEY`, `createSessionToken` throws in production rather than
silently signing something Convex will reject. Pure fail-closed everywhere would
put a hard provisioning dependency in front of every login, which is the failure
mode `lib/hooks/useAuth.ts` lines 485-491 record as "a full production outage"
(PR #1358). Pure fallback everywhere hides a broken production.

## First-time provisioning

1. Generate a pair:

   ```bash theme={null}
   bun run scripts/generate-session-jwt-keypair.ts fm-2026-08
   ```

   It prints every value below and writes nothing to disk. Run it in a terminal
   you are willing to have a private key in.

2. Set `SESSION_JWT_PRIVATE_KEY`, `SESSION_JWT_PUBLIC_KEY` and `SESSION_JWT_KID`
   in Vercel for Production, Preview and Development (and in `.env.local` for
   local work). Redeploy Next.js.

3. Confirm `GET /.well-known/jwks.json` serves the expected `kid`.

4. Set the JWKS on the Convex deployment:

   ```bash theme={null}
   bunx convex env set SESSION_JWKS_DATA_URI "data:text/plain;charset=utf-8;base64,<...>"
   bunx convex env set SESSION_JWT_ISSUER   "https://app.myfundingmachine.com"
   bunx convex env set SESSION_JWT_AUDIENCE "fm-convex"
   ```

   Do this for the dev deployment with the dev issuer too.

5. **Human step:** `convex deploy` (or let the normal Vercel-integrated Convex
   deploy run). `auth.config.ts` reads `process.env` at deploy time, not at Next
   runtime, so the new provider config only takes effect on a deploy.

6. Confirm with the probe query `convex/authProbe.ts:whoAmI`: from an
   authenticated browser session it must return an identity object, not null.

## Observed `getUserIdentity()` shape

Measured, not inferred: minted a token with `createSessionToken` against a Convex
dev deployment with this config pushed, and read back `authProbe:whoAmI`.

```json theme={null}
{
  "subject": "user_probe_123",
  "issuer": "https://dev.myfundingmachine.com",
  "tokenIdentifier": "https://dev.myfundingmachine.com|user_probe_123",
  "userId": "user_probe_123",
  "email": "probe@example.com",
  "userName": "Probe User",
  "locationId": "loc_probe_456",
  "companyId": "co_probe_789",
  "role": "admin",
  "type": "location",
  "authAt": 1785793043
}
```

Every top-level custom claim survives as a top-level member. `aud`, `iat` and
`exp` are consumed by verification and are not exposed. The same query returns
`null` with no token.

**`subject` is not always a person.**
`app/api/auth/location-session/route.ts` synthesizes
`location-user-{locationId}` when the CRM Custom Menu Link surface supplies no
`user_id`, so every operator on that surface for one location shares a `subject`.
Any future tenant check must scope on `locationId`, and should prefer
`identity.tokenIdentifier` over `identity.subject`
(`convex/_generated/ai/guidelines.md` line 207).

## Rotation

The overlap window is already determined by FUND-2053 and needs no new policy
value: no token can outlive `authAt + 24h` (`SESSION_ABSOLUTE_MAX_HOURS = 24`, and
`createSessionToken` bounds `exp` to `min(now + 8h, authAt + 24h)`), so **24 hours
of overlap provably covers every live session.** Use 48h operationally for slack.

1. Generate a new pair with a NEW kid. Never reuse a kid: the overlap becomes
   ambiguous.

2. In Vercel, in one change:
   * `SESSION_JWT_PREV_PUBLIC_KEY` / `SESSION_JWT_PREV_KID` ← the **outgoing**
     public key and kid.
   * `SESSION_JWT_PRIVATE_KEY` / `SESSION_JWT_PUBLIC_KEY` / `SESSION_JWT_KID` ←
     the **new** pair.

3. Redeploy Next.js. New tokens are signed with the new kid; tokens already in
   browsers still verify against the previous entry. Check
   `GET /api/auth/status` shows the new `kid` with `hasPreviousKey: true`.

4. Rebuild the Convex JWKS from the live endpoint, so BOTH kids are present:

   ```bash theme={null}
   curl -s https://app.myfundingmachine.com/.well-known/jwks.json \
     | base64 | tr -d '\n' \
     | sed 's|^|data:text/plain;charset=utf-8;base64,|'
   ```

   Set it: `bunx convex env set SESSION_JWKS_DATA_URI "<that value>"`.

5. **Human step:** `convex deploy`. Until this lands, Convex is still verifying
   against the old JWKS and will reject tokens carrying the new kid — so browser
   Convex subscriptions lose their identity in the gap. Keep steps 3 and 5 close
   together, or do step 5 first with both keys published (Convex accepting a key
   that is not yet in use is harmless).

6. After the overlap window (≥24h, 48h recommended), clear
   `SESSION_JWT_PREV_PUBLIC_KEY` and `SESSION_JWT_PREV_KID`, redeploy Next.js,
   regenerate the single-key data URI, and `convex deploy` once more.

`SESSION_JWT_PREV_PUBLIC_KEY` and `SESSION_JWT_PREV_KID` are set and cleared as a
pair, and `SESSION_JWT_PREV_KID` must differ from `SESSION_JWT_KID`. Setting only
one half does not half-enable the overlap: the kid cannot be published without
key material and the key cannot be addressed without a kid, so the outgoing kid
stops verifying. Reusing the active kid for the previous entry fails the same way
— `kid` is the only selector verification has, so the active key always wins the
lookup and the outgoing key is unreachable. Both states are reported as
`hasPreviousKey: true` with `previousKeyUsable: false` on `GET /api/auth/status`
and warn in the server log, rather than looking identical to a finished
rotation.

A token whose `kid` matches neither the current nor the previous key is rejected
outright. That is the intended end state for the retired key.

## Emergency revocation

Rotating does not invalidate tokens signed by the outgoing key until the previous
entry is removed. To cut them off immediately, skip step 2's `PREV` half: set only
the new pair, redeploy, and every outstanding token fails verification. That logs
every operator out, which is the point.

## What is NOT covered here

The legacy HS256 **verification** path in `lib/auth/session.ts` is retained
deliberately: live sessions carry HS256 tokens for up to 8 hours after deploy, and
`JWT_SECRET` also signs two unrelated report-download token families
(`convex/prospecting/reportsEmail.ts`, `convex/analytics/emailReportNode.ts`).
Removing that path is a separate decision with its own migration window, and it
must keep the pinned per-algorithm allowlist that makes the algorithm-confusion
case in `tests/sessionTokenVerification.test.ts` fail.


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