Skip to content

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

  1. New Kysely migration apps/cms/src/lib/db/migrations/012_user_token_version.ts:
    • Add token_version INTEGER NOT NULL DEFAULT 0 to users.
    • Add a down() that drops the column.
  2. Update apps/cms/src/lib/db/schema.ts UsersTable interface.
  3. Update apps/cms/src/lib/auth/index.ts:
    • Stamp tokenVersion into the JWT on sign-in.
    • On every jwt callback invocation, look up the user row and reject the token (return null) if token_version differs or the user is deactivated.
  4. Add a helper bumpUserTokenVersion(userId) in apps/cms/src/lib/auth/tokenVersion.ts and call it from revocation and credential paths only (see implemented policy below):
    • Role removal (DELETE admin user roles, assignRole only when bumpTokenVersion: true).
    • Password change/reset flows.
    • Tenant membership removal (DELETE admin tenant users).
    • User deactivation and self-delete (DELETE admin users, DELETE /api/me).
  5. Do not bump on pure grants by default: POST role assignment, POST tenant membership add, assignRole() without opt-in, and actor self-grant on tenant create.
  6. Tests in apps/cms/src/lib/auth/__tests__/tokenVersion.test.ts covering: 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_version is part of the CMS Database type.
  • 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 check passes.

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.ts helpers (bumpUserTokenVersion, validateSessionUser, loadTenantSwitchClaims), hardened jwt callback (DB revalidation on every request; tenant switch reloads roles from DB, not client session.roles), bump call sites on role/membership/deactivate/reset-password routes and assignRole; tests in tokenVersion.test.ts and 012_user_token_version.test.ts.
  • 2026-05-24: Local migrate runs per DATA_DIR — use npm run db:migrate:all (dev + ./data/test) after pulling; E2E uses DATA_DIR=./data/test via db:reset in Playwright.
  • 2026-05-25: Rejected approach: bumping token_version on every RBAC grant and tenant-membership add. Initial T-002 implementation bumped all role/membership mutations; that broke Playwright when admin-crud-tenants mutated a seeded tenant-admin fixture and invalidated test/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.