Skip to main content

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

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:
  • 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:
    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:
    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.
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:
    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.