Files

6.9 KiB

Phase 7.2: Security — JTI Claim + Redis Access-Token Revocation - Context

Gathered: 2026-06-05 Status: Ready for planning

## Phase Boundary

Phase 7.2 delivers two security primitives: (1) a jti UUID claim embedded in every new access token, and (2) a user_nbf:{user_id} Redis key checked in get_current_user that immediately invalidates all tokens issued before a security event (password change, TOTP enroll/revoke, account deactivation). This closes the 15-minute gap where a revoked session's live access token remains valid even after its refresh tokens are killed.

No schema migrations. No new endpoints. No frontend changes.

## Implementation Decisions

D-01 — JTI claim

Add jti=str(uuid.uuid4()) to the create_access_token payload in backend/services/auth.py. The jti claim is included on every token but is used only as an identity handle — per-JTI Redis tracking is NOT implemented (user-level NBF replaces it, see D-02).

D-02 — Revocation approach: user-level NBF key, not per-JTI

Set user_nbf:{user_id} = Unix timestamp in Redis (TTL = 15 min, the access token lifetime) when any of these security events fire: change_password, enable_totp, disable_totp, admin account deactivation.

In get_current_user, after a successful decode, read user_nbf:{user_id} from Redis. If the key exists and token["iat"] < nbf_timestamp → raise HTTP 401. This covers ALL sessions uniformly — the calling session's own token is also immediately blocked (its iat predates now()), but it recovers transparently by exchanging its still-live refresh token (which Phase 7.1 D-02 preserved) for a new access token with iat > nbf.

Per-JTI tracking is not added: the jti claim is reserved for Phase 7.3+ (ES256 migration) where it may be needed alongside the algorithm upgrade.

D-03 — Redis access pattern in get_current_user

Use request.app.state.redis directly inside get_current_user. This is consistent with the existing pattern in backend/api/auth.py (lines 197, 566). get_current_user already accepts request: Request, so no signature change is needed. No new get_redis dependency function.

D-04 — Redis failure handling: fail-open

If the Redis GET user_nbf:{user_id} call raises an exception (connection error, timeout), log a warning and allow the request to proceed. Same pattern as the HIBP fail-open in services/auth.py. Availability takes priority over blocking tokens during a Redis outage.

D-05 — Admin account deactivation coverage

The existing deactivation handler in backend/api/admin.py:351 already calls revoke_all_refresh_tokens. Add the user_nbf write there too. The target user's access token (unknown JTI) is fully blocked via the user-level key; no per-JTI lookup needed.

D-06 — TTL for user_nbf key

15 minutes — matches the access token TTL. Keys auto-expire when the last old token would have anyway. No Redis accumulation over time.

<canonical_refs>

Canonical References

Downstream agents MUST read these before planning or implementing.

Authentication service

  • backend/services/auth.pycreate_access_token (~line 86): add jti claim here; revoke_all_refresh_tokens (~line 218): pattern for where to add user_nbf writes; TOTP verify_totp (~line 271): example of Redis access from a service function
  • backend/deps/auth.pyget_current_user (~line 38): the NBF check is added here, after decode_access_token succeeds and before the DB lookup

Admin deactivation

  • backend/api/admin.py:351 — deactivation handler: add user_nbf write here alongside revoke_all_refresh_tokens

Phase 7.1 decisions (must not conflict)

  • .planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-CONTEXT.md — D-02: current refresh token is preserved on privilege change; this is why the 401→refresh recovery in D-02 above works

Security concern definitions

  • .planning/codebase/CONCERNS.md §"No JTI Claim and No JTI Revocation in Redis" — original concern description and fix approach

CLAUDE.md security rules

  • CLAUDE.md §"Login token hardening" — mandates JTI in every token stored in Redis; and "Password change, TOTP enroll/revoke, and account deactivation immediately revoke all active sessions"

</canonical_refs>

<code_context>

Existing Code Insights

Reusable Assets

  • create_access_token in services/auth.py:86 — add jti=str(uuid.uuid4()) to the payload dict; PyJWT encodes it verbatim
  • decode_access_token in services/auth.py:102 — returns the full payload dict; jti is accessible as payload["jti"] after decode
  • app.state.redis — aioredis client, await redis.get(key) returns bytes | None; await redis.set(key, value, ex=seconds) writes with TTL

Established Patterns

  • Redis key style: totp_used:{user_id}:{code} — use same snake_case:colon pattern → user_nbf:{user_id}
  • Fail-open: backend/services/auth.py HIBP check returns False on network errors and logs a warning — mirror this exact pattern for the Redis check in get_current_user
  • request.app.state.redis access: backend/api/auth.py:197 and :566 — copy this inline access pattern verbatim

Integration Points

  • backend/services/auth.pycreate_access_token: only file generating JWTs; change here propagates to all token issuance
  • backend/deps/auth.pyget_current_user: only entry point for validating access tokens on all protected routes; NBF check belongs here
  • backend/api/auth.pychange_password, enable_totp, disable_totp: add await redis.set(f"user_nbf:{user_id}", int(time.time()), ex=900) before session.commit()
  • backend/api/admin.py:351 — deactivation handler: same Redis write pattern

</code_context>

## Specific Ideas
  • The user_nbf value stored in Redis should be a Unix integer timestamp (int(time.time())). In get_current_user, parse it as int(nbf_bytes.decode()) and compare to payload["iat"] (which PyJWT decodes as an int automatically).
  • TTL = 900 seconds (15 * 60) — hardcode this constant alongside ACCESS_TOKEN_EXPIRE_MINUTES in settings or services/auth.py.
  • The jti payload key is lowercase, consistent with RFC 7519. PyJWT accepts arbitrary keys in the payload dict.
## Deferred Ideas
  • Per-JTI Redis tracking — storing each issued JTI in Redis for individual token revocation. Not needed because user-level NBF covers the same use case more efficiently.
  • Phase 7.3 — ES256 algorithm upgrade: Replace HS256 with ECDSA P-256 key pair. The jti claim added in this phase is a prerequisite. Tracked in CONCERNS.md §"JWT Algorithm Downgrade: HS256 Instead of ES256".
  • Phase 7.4 — Token fingerprinting / token binding: Add fgp (fingerprint) claim = HMAC of User-Agent + Accept-Language. Tracked in CONCERNS.md §"No Token Fingerprint / Token Binding".

Phase: 07.2-security-jti-claim-redis-access-token-revocation-inserted Context gathered: 2026-06-05