CMS JWT invalidation via token_version
Problem¶
CMS Auth.js sessions are not invalidated on role, password, or tenant-membership changes. The JWT callback in apps/cms/src/lib/auth/index.ts trusts the existing claims until natural expiry; session.update() is used opportunistically but is not enforced server-side.
LMS solves this with a users.token_version integer that the jwt callback re-validates against the DB on every refresh:
// apps/lms/src/auth/config.ts (lines 148-152)
const current = await findUserById(db, token.userId);
if (!current) return null;
if (current.deactivated_at) return null;
if (current.token_version !== token.tokenVersion) return null;
return token;
CMS needs the same mechanism so that security-sensitive mutations can force re-authentication when access is removed or credentials change.
Proposal¶
- New Kysely migration
apps/cms/src/lib/db/migrations/012_user_token_version.ts:- Add
token_version INTEGER NOT NULL DEFAULT 0tousers. - Add a
down()that drops the column.
- Add
- Update apps/cms/src/lib/db/schema.ts
UsersTableinterface. - Update apps/cms/src/lib/auth/index.ts:
- Stamp
tokenVersioninto the JWT on sign-in. - On every
jwtcallback invocation, look up the user row and reject the token (returnnull) iftoken_versiondiffers or the user is deactivated.
- Stamp
- Add a helper
bumpUserTokenVersion(userId)inapps/cms/src/lib/auth/tokenVersion.tsand call it from revocation and credential paths only (see implemented policy below):- Role removal (
DELETEadmin user roles,assignRoleonly whenbumpTokenVersion: true). - Password change/reset flows.
- Tenant membership removal (
DELETEadmin tenant users). - User deactivation and self-delete (
DELETEadmin users,DELETE/api/me).
- Role removal (
- Do not bump on pure grants by default:
POSTrole assignment,POSTtenant membership add,assignRole()without opt-in, and actor self-grant on tenant create. - Tests in
apps/cms/src/lib/auth/__tests__/tokenVersion.test.tscovering: bump invalidates existing JWT; mismatched version returns no session; matching version passes through.
Implemented session-invalidation policy¶
Pure grants add access and do not invalidate existing JWTs by default. Existing sessions remain a safe subset until the next refresh. Revocations, deactivation, and credential changes invalidate outstanding JWTs immediately by bumping users.token_version.
| Action | Bumps token_version? |
|---|---|
POST assign role / add tenant membership |
No (default) |
POST create tenant (actor self-grant tenant-admin) |
No |
DELETE revoke role / remove tenant membership |
Yes |
DELETE deactivate user, DELETE /api/me |
Yes |
POST reset-password |
Yes |
assignRole(..., { bumpTokenVersion: true }) |
Yes (explicit opt-in only) |
Acceptance criteria¶
- Migration applies and rolls back cleanly on SQLite and Postgres.
-
users.token_versionis part of the CMSDatabasetype. - JWT callback rejects stale tokens.
- Revocation, deactivation, and credential mutations call
bumpUserTokenVersion; pure grants do not bump by default. - New unit tests pass;
npm run checkpasses.
Out of scope¶
- LMS already has this; no LMS changes here.
- UI surfacing of "session expired" state.
- Refresh-token rotation strategy.
Notes / decisions log¶
- 2026-05-24: Ticket created from monorepo audit.
- 2026-05-24: Implemented migration
012_user_token_version,tokenVersion.tshelpers (bumpUserTokenVersion,validateSessionUser,loadTenantSwitchClaims), hardened jwt callback (DB revalidation on every request; tenant switch reloads roles from DB, not clientsession.roles), bump call sites on role/membership/deactivate/reset-password routes andassignRole; tests intokenVersion.test.tsand012_user_token_version.test.ts. - 2026-05-24: Local migrate runs per
DATA_DIR— usenpm run db:migrate:all(dev +./data/test) after pulling; E2E usesDATA_DIR=./data/testviadb:resetin Playwright. - 2026-05-25: Rejected approach: bumping
token_versionon every RBAC grant and tenant-membership add. Initial T-002 implementation bumped all role/membership mutations; that broke Playwright whenadmin-crud-tenantsmutated a seeded tenant-admin fixture and invalidatedtest/e2e/.auth/tenant-admin.json. Agreed policy: grants do not bump by default; revokes and credential/security changes still bump. E2E tests that mutate membership must use disposable users, not seeded fixture identities. See apps/cms/docs/reference/user-management.md.