Skip to content

fix(auth): revoke fallback JWT sessions via jti denylist (CS-01) - #4067

Merged
ety001 merged 2 commits into
nextfrom
fix/cs-01-jwt-fallback-revocation
Oct 19, 2026
Merged

ety001 merged 2 commits into
nextfrom
fix/cs-01-jwt-fallback-revocation

Conversation

@ety001

@ety001 ety001 commented Oct 19, 2026

Copy link
Copy Markdown
Member

Problem (audit finding CS-01, MEDIUM)

When Redis is unavailable, createSession issues a stateless JWT with a 30-day login-session TTL. revokeSession returned immediately for non-Redis token IDs while verifySession kept accepting any still-valid JWT, so logout provided no server-side invalidation and a leaked fallback login JWT remained usable for up to 30 days. (JWT_SECRET strength checks prevent weak-key forgery but confer no revocation capability.)

Design

  • Per-token jti: fallback JWTs are now signed with a fresh random jti on every signing (createSession). The jti is deliberately distinct from the session uid (which is preserved across login/logout rotations), so revoking a superseded token can never reject its replacement. Tokens minted before this change carry no jti; their revocation key is the SHA-256 digest of the full token.
  • Denylist (lib/auth/jwt-denylist.ts, new): revokeSession verifies the token's signature first (so forged input cannot stuff the denylist), then records the key with TTL equal to the token's remaining lifetime:
    • always in-process — module-level bounded map (10k entries, lazy expiry + oldest-first eviction with touch-on-read), honored by the instance that served the logout even with Redis fully down;
    • mirrored to Redis when reachable — revoked-jwt: keys under the existing session-store namespace (lib/auth/redis-session.ts gained setDenylistEntry/hasDenylistEntry), so other instances honor the revocation; an un-mirrored entry retries its mirror the next time its key is consulted, which propagates revocations made during an outage once Redis recovers.
  • verifySession rejects a fallback JWT whose key is denylisted (in-process + Redis mirror), treating it like an expired signature.
  • Redis session path unchanged: sids are still revoked by key deletion (30-day TTL intact); the denylist is only consulted on the JWT verification path.

TTL decision

Logged-in fallback JWT TTL drops from 30 days to 12 hours (FALLBACK_PERSISTENT_JWT_TTL_SEC = 43200). Justification: these tokens are minted precisely when Redis is down, i.e. when revocation can only be honored per-instance — the shorter TTL bounds a leaked token's exposure to half a day even when revocation cannot propagate. The trade-off (a Redis outage longer than 12h logs fallback users out once) is acceptable next to an unrevokable 30-day credential. Active users are not disturbed: every session-touching request re-signs the fallback JWT (updateSession → createSession), so the 12-hour window slides while the token is in use. Challenge (anonymous) fallback JWTs keep their 600s TTL; Redis-backed sessions keep the documented 30-day TTL.

Residual risk (documented in docs/AUTHENTICATION.md)

During a full Redis outage in a multi-instance deployment, revocation is only honored by the instance that served the logout until Redis recovers (lazy re-mirror on next consultation of the key, and new revocations mirror immediately). This is strictly better than the previous behavior where logout did nothing at all for fallback tokens, and the exposure is bounded by the 12-hour TTL. Production deployments should still configure REDIS_URL for immediate cluster-wide revocation.

Test evidence

__tests__/lib/auth/jwt-denylist.test.ts (new, 12 tests) and extended __tests__/lib/auth/session.test.ts:

  • core audit scenario: fallback session created without Redis → revokeSession(token) → verifySession(token) returns null, while a concurrently issued token still verifies and the rotated post-logout token stays valid;
  • denylist entries expire at the token's original exp (1 ms before → revoked; at exp → not), mirror TTL equals the remaining lifetime (+1 s ceil slack at most);
  • Redis-mirror path: write-once on success, lazy re-mirror after recovery, cross-instance revocation honored, and no-Redis/no-throw degradation;
  • TTLs: fallback persistent JWT now 43200s, challenge fallback 600s unchanged, Redis sessions 2592000s unchanged;
  • in-process map is bounded (10k cap, oldest-first eviction under overflow);
  • forged/unverifiable tokens are not denylisted.

pnpm test: 86 files / 1082 tests passing. pnpm lint: unchanged baseline (7 warnings + 1 pre-existing error in __tests__/proxy.test.ts:383, untouched). tsc --noEmit: clean.

Logout previously had no server-side effect on stateless fallback JWTs
(issued when Redis is unavailable): revokeSession returned early for
non-Redis tokens, so a leaked login JWT stayed valid for its full 30-day
TTL. Fallback JWTs now carry a unique per-token jti (SHA-256 token digest
for pre-rollout tokens) which revokeSession records in a denylist that
lives until the token's own exp — always in-process on the serving
instance, mirrored to Redis (revoked-jwt: keys, TTL = remaining lifetime)
when reachable so other instances honor it, and re-mirrored lazily after
an outage. verifySession rejects denylisted keys, and the logged-in
fallback TTL drops from 30 days to a sliding 12 hours to bound the
exposure window when revocation cannot propagate; Redis-backed sessions
keep their 30-day TTL and deletion-based revocation unchanged.
The Redis mirror keys are 'revoked-jwt:<key>' under the session key
prefix only in REDIS_HOST mode (client keyPrefix); REDIS_URL mode
deliberately keeps unprefixed session keys, so the denylist keys are
raw there. Align docs/AUTHENTICATION.md and the redis-session.ts
comment with the actual key layout.
@ety001
ety001 force-pushed the fix/cs-01-jwt-fallback-revocation branch from 1b53f34 to dfd83f6 Compare October 19, 2026 15:50
@ety001
ety001 merged commit b094268 into next Oct 19, 2026
0 of 2 checks passed
@ety001
ety001 deleted the fix/cs-01-jwt-fallback-revocation branch October 19, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant