Session JWT signing key: provisioning and rotation runbook
Owner: whoever holds Vercel + Convex production access. Rotation permanently involves a humanconvex 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.comin production,https://dev.myfundingmachine.comeverywhere else. “Production” meansVERCEL_ENV === "production", falling back toNODE_ENV === "production"whenVERCEL_ENVis absent.VERCEL_ENVis checked first because Next setsNODE_ENV=productionfor 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_URIif set, otherwise the issuer-derivedhttps://<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.
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
-
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.
-
Set
SESSION_JWT_PRIVATE_KEY,SESSION_JWT_PUBLIC_KEYandSESSION_JWT_KIDin Vercel for Production, Preview and Development (and in.env.localfor local work). Redeploy Next.js. -
Confirm
GET /.well-known/jwks.jsonserves the expectedkid. -
Set the JWKS on the Convex deployment:
Do this for the dev deployment with the dev issuer too.
-
Human step:
convex deploy(or let the normal Vercel-integrated Convex deploy run).auth.config.tsreadsprocess.envat deploy time, not at Next runtime, so the new provider config only takes effect on a deploy. -
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.
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 outliveauthAt + 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.
- Generate a new pair with a NEW kid. Never reuse a kid: the overlap becomes ambiguous.
-
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.
-
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/statusshows the newkidwithhasPreviousKey: true. -
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>". -
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). -
After the overlap window (≥24h, 48h recommended), clear
SESSION_JWT_PREV_PUBLIC_KEYandSESSION_JWT_PREV_KID, redeploy Next.js, regenerate the single-key data URI, andconvex deployonce 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’sPREV 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 inlib/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.
