[SECURITY AUDIT] Session/token revocation is a non-functional stub — logout and password change do not invalidate existing JWTs #30

Closed
opened 2026-07-04 22:00:33 +00:00 by wtclaude · 0 comments
Member

Severity: Medium
Confidence: High
Scope area: Session management & token expiry (audit area 4)

What

Sessions are stateless JWTs and there is no server-side session store or denylist. The revocation hooks in server/src/auth/session.service.js (revokeSession, invalidateSession, invalidateAllUserSessions, lines ~187-200) are stubs that only log and return true. As a result:

  • Web logout is client-side only. logout in server/src/router/v1/auth/auth.controller.js:99-102 calls clearAuthCookie — it clears the cookie in the caller's browser but does nothing to the token itself. A copy of the cookie value (e.g. captured from a shared/compromised machine, a proxy log, or an XSS-exfiltrated value) remains a fully valid session until the JWT's natural expiry (JWT_EXPIRES_IN, default 1 day).
  • Password change does not invalidate old sessions. updateUser / users.update (server/src/router/v1/admin/admin.controller.js:505, server/src/model/users/users.model.js:55) rehash the password but never call any invalidation. An attacker with a stolen token keeps access for up to a day even after the victim rotates their password — the usual "change password to kick everyone out" expectation silently fails.

requireAuth re-reads the user row on each request, so a deleted or role-demoted user does lose access promptly — but an intact user with a stolen still-valid token does not, and there is no way to force-expire it.

Why it matters

Token theft has no containment path. The two standard mitigations users expect — "log out everywhere" and "change password to revoke sessions" — are both no-ops here. The mobile bearer flow got this right (opaque, DB-stored, revocable refresh tokens); the web/cookie session flow has no equivalent.

Where

  • server/src/auth/session.service.js:182-200 (stub revocation)
  • server/src/router/v1/auth/auth.controller.js:99-102 (logout)
  • server/src/auth/token.js:16 (JWT_EXPIRES_IN default 1d)
  • server/src/auth/session.middleware.js:35-49 (requireAuth — DB revalidation covers deleted/demoted but not token revocation)

Suggested fix (not implemented)

Introduce a minimal server-side session/denylist keyed on the JWT jti (already minted per session in createSession), or a per-user token_valid_after timestamp column checked in requireAuth. On logout, add the jti to the denylist; on password change, bump token_valid_after so all older tokens fail validation. Shorten JWT_EXPIRES_IN as an interim mitigation. The existing jti claim and the invalidate* hook points are already in place for this.

**Severity:** Medium **Confidence:** High **Scope area:** Session management & token expiry (audit area 4) ### What Sessions are stateless JWTs and there is no server-side session store or denylist. The revocation hooks in `server/src/auth/session.service.js` (`revokeSession`, `invalidateSession`, `invalidateAllUserSessions`, lines ~187-200) are stubs that only log and return `true`. As a result: - **Web logout is client-side only.** `logout` in `server/src/router/v1/auth/auth.controller.js:99-102` calls `clearAuthCookie` — it clears the cookie in the caller's browser but does nothing to the token itself. A copy of the cookie value (e.g. captured from a shared/compromised machine, a proxy log, or an XSS-exfiltrated value) remains a fully valid session until the JWT's natural expiry (`JWT_EXPIRES_IN`, default **1 day**). - **Password change does not invalidate old sessions.** `updateUser` / `users.update` (`server/src/router/v1/admin/admin.controller.js:505`, `server/src/model/users/users.model.js:55`) rehash the password but never call any invalidation. An attacker with a stolen token keeps access for up to a day even after the victim rotates their password — the usual "change password to kick everyone out" expectation silently fails. `requireAuth` re-reads the user row on each request, so a **deleted or role-demoted** user does lose access promptly — but an intact user with a stolen still-valid token does not, and there is no way to force-expire it. ### Why it matters Token theft has no containment path. The two standard mitigations users expect — "log out everywhere" and "change password to revoke sessions" — are both no-ops here. The mobile bearer flow got this right (opaque, DB-stored, revocable refresh tokens); the web/cookie session flow has no equivalent. ### Where - `server/src/auth/session.service.js:182-200` (stub revocation) - `server/src/router/v1/auth/auth.controller.js:99-102` (logout) - `server/src/auth/token.js:16` (`JWT_EXPIRES_IN` default `1d`) - `server/src/auth/session.middleware.js:35-49` (`requireAuth` — DB revalidation covers deleted/demoted but not token revocation) ### Suggested fix (not implemented) Introduce a minimal server-side session/denylist keyed on the JWT `jti` (already minted per session in `createSession`), or a per-user `token_valid_after` timestamp column checked in `requireAuth`. On logout, add the `jti` to the denylist; on password change, bump `token_valid_after` so all older tokens fail validation. Shorten `JWT_EXPIRES_IN` as an interim mitigation. The existing `jti` claim and the `invalidate*` hook points are already in place for this.
wtclaude added the
bug
severity:medium
labels 2026-07-04 22:00:33 +00:00
Sign in to join this conversation.
No description provided.