Disabling a user or downgrading their role terminates their live sessions, not just future logins
Why it matters
This evidences SOC 2 CC6.2, which requires that access is removed when no longer authorized: an auditor reads "removes access" to include in-flight sessions, not just the next login attempt. AI-generated apps fail this in a very specific way: the scaffold ships stateless JWTs with long lifetimes (next-auth defaults to 30 days) and an admin "ban user" route that flips a banned or is_active column, and nothing ever consults that column again until the token expires. The result is a deprovisioning gap where a fired employee, a compromised account, or a demoted admin keeps full live access for days or weeks after the org believes it was cut off. That gap is exactly what a SOC 2 auditor probes during the logical-access walkthrough ("show me what happens to an active session when you disable this user"), and it is the difference between a clean CC6.2 test and an exception in the report.
Severity rationale
High because a disabled or demoted user retains their full prior access for the entire remaining token or session lifetime, a live deprovisioning gap that defeats the point of the disable action; it is not critical because exploitation requires an account that was already legitimately provisioned.
Remediation
In every handler that disables a user or downgrades a role, revoke their live access in the same code path: call supabase.auth.admin.signOut(userId) (or clerkClient.sessions.revokeSession(...) per active session, or prisma.session.deleteMany({ where: { userId } }) for database sessions) immediately after the status write. If you are on stateless JWTs, either shorten the access-token lifetime to one hour or less and revoke refresh tokens on disable, or add a per-request check in middleware.ts that re-reads is_active and role from the database so stale sessions die on the next request.
Detection
- ID:
sessions-revoked-on-access-change - Severity:
high - What to look for: First find the user-disable / role-mutation surfaces: admin routes under
app/api/admin/**,pages/api/admin/**, or Express routers mounted at/admin; server actions or handlers matching/(ban|suspend|disable|deactivate|demote|revoke|set[_-]?Role|update[_-]?Role)/i; DB writes touchingrole,is_active,banned,disabled,suspended_at, orstatuson ausers/profiles/memberstable (prisma.user.update,supabase.from('users').update, rawUPDATE users SET role); admin SDK calls (supabase.auth.admin.updateUserById,clerkClient.users.updateUser, Auth0ManagementClientuser updates,firebase-adminupdateUser({ disabled: true })). Python/Go equivalents: Djangouser.is_active = Falseor DRF admin viewsets; Go handlers doingUPDATE users SET role/active. If no such surface exists anywhere, skip. Enumerate every disable/role-mutation surface you find and quote the status/role write line for each; then for each surface trace whether its path revokes live access via any of: (a) server-side session invalidation in the same path (prisma.session.deleteMany,DELETE FROM sessions WHERE user_id,redis.del('sess:...'), LuciainvalidateUserSessions, DjangoSession.objects.filter(...).delete()); (b) an admin revocation call (supabase.auth.admin.signOut(userId),clerkClient.sessions.revokeSession, Auth0 Management API session/refresh-token revocation,firebase-adminrevokeRefreshTokens); (c) access-token lifetime <= 1 hour (session.maxAge,expiresIn,jwt.sign(..., { expiresIn }), Supabasejwt_expiryinsupabase/config.toml) combined with refresh-token revocation on disable; (d) per-request middleware (middleware.ts, Express/Fastify auth middleware, Django middleware) that re-readsis_active/rolefrom the database rather than trusting JWT claims. - Pass criteria: At least one disable/role-mutation surface exists, and every such path (directly or via a shared helper it demonstrably calls) contains mechanism (a), (b), (c), or (d), with the revocation call or DB re-read actually invoked in code, quoted as evidence. Report the count even on pass: N mutation surfaces found, N of N wired to a revocation mechanism.
- Fail criteria: A disable/role-mutation surface exists, none of its paths touches sessions or tokens, and sessions are long-lived (JWT/session
maxAgeover 24 hours, or unset with a long-lived default such as next-auth's 30 days). Also fail the sneaky variants: a revocation helper (e.g.invalidateUserSessions) defined but never called from any mutation path; asignOut/revoke call commented out or behind a permanently false flag; middleware that readsrole/bannedonly from JWT claims (stale by definition) instead of the database; a disable handler that is a stub returning success without writing or revoking anything. Do NOT pass when only some mutation surfaces revoke while others do not, when the sole mechanism is short-lived access tokens with no refresh-token revocation on disable, or when revocation exists only in a comment, README, or dead code path. - Skip (N/A) when: The codebase has no user-disable or role-mutation surface at all (no admin user management). Quote:
"No admin user-management surface: no routes, server actions, or admin SDK calls mutating role/is_active/banned on any user table". - Before evaluating, quote: The mutation-surface file path plus 1-3 lines of the status/role write, AND either the revocation mechanism excerpt (session delete, admin signOut, token-lifetime config, or middleware DB re-read) or the session-lifetime config proving tokens are long-lived with no revocation. If skipping, quote the search scope that proves no mutation surface exists.
- Report even on pass:
"Disable/role surface at <path>; revocation mechanism: session-store delete | admin signOut | short-lived tokens + refresh revocation | per-request DB status check at <path>". - Detail on fail:
"app/api/admin/users/[id]/route.ts sets is_active=false via prisma.user.update but never touches the sessions table; next-auth uses JWT strategy with default 30-day maxAge, so a banned user keeps live access until token expiry"or"invalidateUserSessions() is defined in src/lib/auth.ts but has no callers; the ban server action in app/admin/actions.ts only flips users.banned and middleware.ts trusts the role claim in the JWT". - Remediation: In the disable/downgrade handler, revoke live access in the same code path as the status write:
supabase.auth.admin.signOut(userId),clerkClient.sessions.revokeSession(...), orprisma.session.deleteMany({ where: { userId } }). On stateless JWTs, shorten access-token lifetime to <= 1 hour and revoke refresh tokens on disable, or add amiddleware.tscheck that re-readsis_activeandrolefrom the database per request. Deeper session-hardening coverage: thesaas-authenticationPro audit.
External references
- soc2:2017 · CC6.2 — User registration, authorization, and access removal
- iso-27001:2022 · A.5.18 — Access rights
- nist:rev5 · AC-2 — Account Management
Taxons
History
- 2026-07-03·v1.0.0·Initial soc2-readiness v1 authoring; CC6.2 deprovisioning must cover in-flight sessions, so disable and role-downgrade paths need session revocation, short-lived tokens with refresh revocation, or per-request status re-reads.·by soc2-readiness-v1-build