# Disabling a user or downgrading their role terminates their live sessions, not just future logins

- **Pattern:** `ab-002627` (`soc2-readiness.logical-access.sessions-revoked-on-access-change`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-07-03
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002627
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002627)

## 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 touching `role`, `is_active`, `banned`, `disabled`, `suspended_at`, or `status` on a `users`/`profiles`/`members` table (`prisma.user.update`, `supabase.from('users').update`, raw `UPDATE users SET role`); admin SDK calls (`supabase.auth.admin.updateUserById`, `clerkClient.users.updateUser`, Auth0 `ManagementClient` user updates, `firebase-admin` `updateUser({ disabled: true })`). Python/Go equivalents: Django `user.is_active = False` or DRF admin viewsets; Go handlers doing `UPDATE 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:...')`, Lucia `invalidateUserSessions`, Django `Session.objects.filter(...).delete()`); (b) an admin revocation call (`supabase.auth.admin.signOut(userId)`, `clerkClient.sessions.revokeSession`, Auth0 Management API session/refresh-token revocation, `firebase-admin` `revokeRefreshTokens`); (c) access-token lifetime <= 1 hour (`session.maxAge`, `expiresIn`, `jwt.sign(..., { expiresIn })`, Supabase `jwt_expiry` in `supabase/config.toml`) combined with refresh-token revocation on disable; (d) per-request middleware (`middleware.ts`, Express/Fastify auth middleware, Django middleware) that re-reads `is_active`/`role` from 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 `maxAge` over 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; a `signOut`/revoke call commented out or behind a permanently false flag; middleware that reads `role`/`banned` only 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(...)`, or `prisma.session.deleteMany({ where: { userId } })`. On stateless JWTs, shorten access-token lifetime to <= 1 hour and revoke refresh tokens on disable, or add a `middleware.ts` check that re-reads `is_active` and `role` from the database per request. Deeper session-hardening coverage: the `saas-authentication` Pro audit.

## External references

- soc2 CC6.2
- iso-27001 A.5.18
- nist AC-2

Taxons: access-control, regulatory-conformance

HTML version: https://auditbuffet.com/patterns/ab-002627
