# Phase 7.2: Security — JTI Claim + Redis Access-Token Revocation - Research **Researched:** 2026-06-05 **Domain:** JWT security, Redis-backed token revocation, FastAPI dependency injection **Confidence:** HIGH --- ## User Constraints (from CONTEXT.md) ### Locked 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). **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. **D-03 — Redis access pattern in get_current_user** Use `request.app.state.redis` directly inside `get_current_user`. Consistent with existing pattern in `backend/api/auth.py` lines 197 and 566. 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, log a warning and allow the request to proceed. Mirrors the HIBP fail-open pattern in `services/auth.py`. **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. **D-06 — TTL for user_nbf key** 15 minutes (900 seconds) — matches the access token TTL. ### Claude's Discretion None specified. ### Deferred Ideas (OUT OF SCOPE) - **Per-JTI Redis tracking** — storing each issued JTI in Redis for individual token revocation. - **Phase 7.3 — ES256 algorithm upgrade**: Replace HS256 with ECDSA P-256. The jti claim added in this phase is a prerequisite. - **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = HMAC of `User-Agent + Accept-Language`. --- ## Summary Phase 7.2 closes a 15-minute security gap: after Phase 7.1 revokes all refresh tokens on a security event (password change, TOTP enroll/revoke, account deactivation), the attacker's live access token remains valid until its 15-minute TTL expires. This phase eliminates that window by embedding a `jti` UUID claim in every new access token and introducing a `user_nbf:{user_id}` Redis key that invalidates all tokens issued before the security event. The implementation touches exactly four files: `backend/services/auth.py` (add `jti` to `create_access_token`), `backend/deps/auth.py` (add NBF check in `get_current_user`), `backend/api/auth.py` (`change_password`, `enable_totp`, `disable_totp` — write Redis key), and `backend/api/admin.py` (deactivation handler — write Redis key). No schema migrations, no new endpoints, no frontend changes. The approach is a user-level "not-before" timestamp rather than per-JTI revocation. Writing one Redis key per security event is O(1) and leaves no accumulation. The key auto-expires after 15 minutes (matching access token TTL), so Redis memory pressure is negligible. **Primary recommendation:** Implement exactly as specified in D-01 through D-06. The patterns are already proven in this codebase (TOTP replay prevention uses the identical Redis key-style and TTL approach). The only new import needed in `api/auth.py` and `api/admin.py` is `import time` — all other dependencies are already present. --- ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | JTI claim generation | API / Backend (`services/auth.py`) | — | Token creation is a pure service function; JTI is a UUID added to the payload dict before signing | | NBF check on every request | API / Backend (`deps/auth.py`) | — | FastAPI dep chain; runs before every protected route handler | | user_nbf Redis write on security event | API / Backend (`api/auth.py`, `api/admin.py`) | — | Handlers own the security event logic; Redis write belongs alongside the existing `revoke_all_refresh_tokens` call | | Redis storage for user_nbf | Database / Storage (Redis) | — | TTL-keyed ephemeral state; same store as TOTP replay prevention and rate limiting | --- ## Standard Stack ### Core (no new packages required) | Library | Current Version | Purpose | Already Used | |---------|----------------|---------|-------------| | `PyJWT` | 2.13.0 [VERIFIED: runtime] | JWT encoding/decoding; `jti` is an arbitrary payload key — accepted verbatim | Yes, `services/auth.py` | | `redis` (async) | `>=4.6.0` in requirements.txt | `await redis.get(key)` / `await redis.set(key, value, ex=seconds)` | Yes, `app.state.redis` | | `uuid` (stdlib) | 3.x | `str(uuid.uuid4())` for JTI value | Yes, `services/auth.py` | | `time` (stdlib) | 3.x | `int(time.time())` for Unix timestamp in user_nbf key | Needs `import time` added to `api/auth.py` and `api/admin.py` | ### No new packages to install This phase adds zero new dependencies. All required functionality is already present in the installed libraries and Python stdlib. --- ## Package Legitimacy Audit > No external packages are added in this phase. This section is intentionally blank. **Packages removed due to slopcheck [SLOP] verdict:** none **Packages flagged as suspicious [SUS]:** none --- ## Architecture Patterns ### System Architecture Diagram ``` Security Event (change_password / enable_totp / disable_totp / admin deactivate) │ ▼ api/auth.py or api/admin.py │ (already) revoke_all_refresh_tokens(session, user_id, ...) │ (NEW) await redis.set(f"user_nbf:{user_id}", int(time.time()), ex=900) │ ▼ Redis: user_nbf:{user_id} = [TTL=900s] Every API request (any protected route): Authorization: Bearer │ ▼ deps/auth.py :: get_current_user │ ├─ decode_access_token → payload (sub, role, iat, jti, ...) │ ├─ (NEW) nbf_bytes = await request.app.state.redis.get(f"user_nbf:{payload['sub']}") │ if nbf_bytes and payload["iat"] < int(nbf_bytes.decode()): │ raise HTTP 401 "Session invalidated" │ └─ session.get(User, user_uuid) → return user Token issuance (every login / refresh): services/auth.py :: create_access_token │ (NEW) jti=str(uuid.uuid4()) added to payload └─ jwt.encode(payload, ...) → signed JWT ``` ### Recommended Project Structure No new files. Changes are surgical edits to existing files: ``` backend/ ├── services/auth.py # add jti= to create_access_token payload ├── deps/auth.py # add user_nbf Redis check in get_current_user ├── api/auth.py # add redis write to change_password, enable_totp, disable_totp └── api/admin.py # add redis write to deactivation handler ``` ### Pattern 1: Adding jti to create_access_token **What:** Insert `jti=str(uuid.uuid4())` into the payload dict before signing. **When to use:** Always. PyJWT encodes arbitrary payload keys verbatim. The `jti` key is RFC 7519 standard (lowercase). ```python # Source: CONTEXT.md D-01 + PyJWT 2.13.0 verified behavior import uuid def create_access_token(user_id: str, role: str) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "role": role, "typ": "access", "iat": now, "exp": now + timedelta(minutes=settings.access_token_expire_minutes), "jti": str(uuid.uuid4()), # ← only change } return jwt.encode(payload, settings.secret_key, algorithm="HS256") ``` **Verified fact:** PyJWT 2.13.0 encodes `iat` as a Unix integer when a `datetime` object is passed. `payload["iat"]` after `decode()` is an `int`. `int(time.time())` is also an `int`. Direct `<` comparison works correctly. [VERIFIED: runtime test — `iat type: ` confirmed in this session] ### Pattern 2: user_nbf Redis write on security event **What:** Write `user_nbf:{user_id}` = `int(time.time())` with TTL=900 to Redis immediately before `session.commit()` on any security event. **When to use:** In `change_password`, `enable_totp`, `disable_totp`, and the admin deactivation handler. ```python # Source: CONTEXT.md D-02, D-03, D-06; mirrors totp_used pattern in services/auth.py import time # new import # Inside handler, after revoke_all_refresh_tokens, before session.commit(): redis_client = request.app.state.redis await redis_client.set(f"user_nbf:{current_user.id}", int(time.time()), ex=900) ``` **Key facts:** - TTL = 900 = `ACCESS_TOKEN_EXPIRE_MINUTES * 60` = 15 * 60. Keys auto-expire when the oldest possible old token would have expired anyway. [VERIFIED: config.py `access_token_expire_minutes: int = 15`] - `request.app.state.redis` is the aioredis client already confirmed at lines 197 and 566 of `api/auth.py`. - Admin deactivation uses `request` from handler signature — same access pattern applies. ### Pattern 3: NBF check in get_current_user **What:** After `decode_access_token` succeeds, check Redis for `user_nbf:{user_id}`. If set and `payload["iat"] < stored_timestamp`, raise HTTP 401. **When to use:** In `deps/auth.py::get_current_user`, after the decode block, before the DB lookup. ```python # Source: CONTEXT.md D-02, D-03, D-04; mirrors HIBP fail-open pattern in services/auth.py import logging _logger = logging.getLogger(__name__) async def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), session: AsyncSession = Depends(get_db), ) -> User: try: payload = auth_service.decode_access_token(credentials.credentials) except ValueError as exc: raise HTTPException(status_code=401, ...) from exc # ── user_nbf check (NEW) ──────────────────────────────────────────── try: redis = request.app.state.redis nbf_bytes = await redis.get(f"user_nbf:{payload['sub']}") if nbf_bytes is not None and payload["iat"] < int(nbf_bytes.decode()): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Session invalidated", headers={"WWW-Authenticate": "Bearer"}, ) except HTTPException: raise # re-raise the 401 we just constructed except Exception as exc: _logger.warning("Redis user_nbf check failed (fail-open): %s", exc) # ── end user_nbf check ────────────────────────────────────────────── try: user_uuid = uuid.UUID(payload["sub"]) except (KeyError, ValueError) as exc: raise HTTPException(status_code=401, ...) from exc user = await session.get(User, user_uuid) if user is None or not user.is_active: raise HTTPException(status_code=401, ...) request.state.current_user = user return user ``` **Critical detail:** The `except Exception` that implements fail-open must explicitly re-raise `HTTPException` first, otherwise it would swallow the intentional 401 we just raised. [ASSUMED — standard Python try/except behavior, no special framework wrapping needed here, but the planner must verify the exception-in-except pattern is correct] ### Pattern 4: Admin deactivation handler (api/admin.py ~line 351) **What:** Add Redis write immediately after the existing `revoke_all_refresh_tokens` call. **When to use:** When `not body.is_active` — same condition as the existing revocation. ```python # Source: CONTEXT.md D-05; existing handler at admin.py:351 if not body.is_active: await revoke_all_refresh_tokens(session, user.id) # (NEW) immediately block any live access token await request.app.state.redis.set( f"user_nbf:{user.id}", int(time.time()), ex=900 ) ``` **Import needed:** `import time` at the top of `api/admin.py` (check if already present). ### Anti-Patterns to Avoid - **Do not swallow HTTPException in the fail-open catch:** The `except Exception as exc` block must have a `raise` for `HTTPException` before the general catch. Otherwise a valid revocation check (iat < nbf) will silently pass through as fail-open. - **Do not use `datetime`-based comparison for iat vs nbf:** `payload["iat"]` is an int (verified above). `int(time.time())` is also an int. Use integer comparison — no timezone conversion needed. - **Do not write user_nbf for successful activation (`body.is_active == True`):** The Redis write is only needed on deactivation. Writing it on activation would block the just-reactivated user's next request. - **Do not add `import time` inside handler bodies:** Add it at the module level of `api/auth.py` and `api/admin.py` following the existing module-level imports. - **Do not set TTL to `settings.access_token_expire_minutes * 60` computed inline:** Hardcode 900 or define `ACCESS_TOKEN_NBF_TTL = 15 * 60` as a module-level constant next to `create_access_token`. The constant TTL is intentional and should be obvious. --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | UUID generation for jti | Custom random hex | `str(uuid.uuid4())` | Already imported in `services/auth.py`; RFC 7519 compliant; collision-free | | Unix timestamp | Custom datetime arithmetic | `int(time.time())` | Direct, correct, no timezone confusion | | Redis TTL management | Python-side expiry tracking | `redis.set(..., ex=900)` | Atomic server-side TTL; correct even if the app crashes after writing | | Fail-open Redis errors | Try/except that re-raises | Logger warning + proceed | Matches HIBP pattern already in codebase; availability > availability of revocation during outage | **Key insight:** This phase is almost entirely plumbing existing infrastructure. The TOTP replay prevention (`totp_used:{user_id}:{code}` with TTL=90) is a direct template for `user_nbf:{user_id}` with TTL=900. --- ## Common Pitfalls ### Pitfall 1: Swallowing the HTTPException in fail-open **What goes wrong:** The `except Exception as exc` catch intended for Redis errors silently catches the `HTTPException(401)` raised by the iat-vs-nbf check, allowing a revoked token through. **Why it happens:** Python's `except Exception` catches all exceptions including `HTTPException`. **How to avoid:** Always add `except HTTPException: raise` before the broad `except Exception` catch in the user_nbf check block. **Warning signs:** Test that verifies NBF revocation passes in isolation but fails when run inside a try/except block — the 401 never reaches the test assertion. ### Pitfall 2: Wrong comparison direction **What goes wrong:** Checking `nbf_timestamp < payload["iat"]` instead of `payload["iat"] < nbf_timestamp` — logic is inverted; tokens issued AFTER the event are blocked, tokens issued BEFORE are allowed. **Why it happens:** Easy to flip the inequality when reading "token issued before NBF is invalid." **How to avoid:** Read it as: "token's issue time (iat) is EARLIER THAN the not-before timestamp (nbf) → invalid." `payload["iat"] < nbf_timestamp` → raise 401. **Warning signs:** Security test where a token issued before the event still works — the revocation is a no-op. ### Pitfall 3: Blocking the recovering session **What goes wrong:** The user changes their password (security event) → user_nbf is written → user's own current session's access token is also blocked → next API call returns 401 → frontend must refresh the access token. **Why this is intentional, not a bug:** The CONTEXT.md D-02 explicitly states: "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." The frontend's silent refresh path handles this. No code change is needed to "fix" this — it is the intended behavior. **Warning signs:** A test that calls change_password and then immediately calls `/api/auth/me` WITHOUT refreshing the access token → will get 401. This is correct. The test must refresh first. ### Pitfall 4: Redis not available in test fixtures **What goes wrong:** `get_current_user` calls `request.app.state.redis` but the test app (created via `make_test_app()` in `test_auth_deps.py`) has no `app.state.redis` set → `AttributeError`. **Why it happens:** The minimal test app in `test_auth_deps.py` does not mount the full lifespan that creates `app.state.redis`. The new NBF check will crash with `AttributeError` on any test using that fixture. **How to avoid:** In `test_auth_deps.py`, add a FakeRedis instance to `app.state.redis` before tests run. Since the NBF key will not be set in the fake Redis, the check will pass through (no key = no block). The existing FakeRedis class in `test_auth_api.py` already supports `get` and `set` — reuse it or import it. **Warning signs:** `AttributeError: 'State' object has no attribute 'redis'` in any test that exercises `get_current_user`. ### Pitfall 5: admin.py does not import `time` **What goes wrong:** `int(time.time())` raises `NameError: name 'time' is not defined`. **Why it happens:** `api/admin.py` does not currently import the `time` stdlib module (grep confirms no `import time` there). **How to avoid:** Add `import time` to the module-level imports of `api/admin.py`. **Warning signs:** `NameError` in the deactivation handler during testing. ### Pitfall 6: TTL mismatch between user_nbf write sites **What goes wrong:** One handler writes TTL=900 and another writes a different value, creating inconsistent revocation windows. **How to avoid:** Define `_USER_NBF_TTL = 900` as a module-level constant in `api/auth.py` (or in `config.py` as `access_token_expire_minutes * 60`). Use the constant in all four write sites. In `api/admin.py`, import the constant or hardcode 900 with a comment referencing it. --- ## Code Examples ### Verified: PyJWT iat encoding behavior ```python # Verified in this research session with PyJWT 2.13.0 # payload["iat"] after jwt.decode() is always an int (Unix timestamp) # int(time.time()) is also an int # Direct integer comparison works: payload["iat"] < int(nbf_bytes.decode()) decoded = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) type(decoded["iat"]) # ← verified ``` [VERIFIED: runtime test — see research session output] ### Verified: Redis set with TTL ```python # Source: services/auth.py::verify_totp (existing pattern, line 296) await redis_client.set(replay_key, "1", ex=90) # Same pattern for user_nbf: await redis_client.set(f"user_nbf:{user_id}", int(time.time()), ex=900) ``` [VERIFIED: codebase — `services/auth.py:296`] ### Verified: Redis get returning bytes ```python # Source: services/auth.py::verify_totp (existing pattern, line 289) if await redis_client.get(replay_key): return False # For user_nbf, need the value (not just truthiness): nbf_bytes = await redis_client.get(f"user_nbf:{payload['sub']}") if nbf_bytes is not None and payload["iat"] < int(nbf_bytes.decode()): raise HTTPException(...) ``` [VERIFIED: codebase — `services/auth.py:289`; `redis.get()` returns `bytes | None`] ### Verified: Fail-open pattern ```python # Source: services/auth.py::check_hibp (lines 394-396) — exact template try: # ... Redis call ... except Exception as exc: logger.warning("HIBP check failed (fail-open): %s", exc) return False ``` [VERIFIED: codebase — `services/auth.py:394`] --- ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | No token revocation after security event (access token valid until TTL) | user-level NBF key in Redis; closes 15-min gap | Phase 7.2 | Deactivated users cannot continue using live access tokens | | No jti claim | `jti=str(uuid.uuid4())` in every access token | Phase 7.2 | Prerequisite for Phase 7.3 ES256 migration; enables future per-JTI revocation | **Deferred / out of scope:** - Per-JTI revocation (one Redis key per token): more granular but not needed; user-NBF covers all sessions uniformly and is O(1) writes vs O(N) for N active sessions. - ES256 algorithm upgrade (Phase 7.3): jti claim is its prerequisite. - Token fingerprinting (Phase 7.4): fgp claim not in scope here. --- ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | The `except Exception` catch in the fail-open block must have an explicit `except HTTPException: raise` guard before it | Architecture Patterns, Pattern 3 | If wrong (e.g., FastAPI wraps HTTPException in a different base), the guard might be unnecessary boilerplate — low risk, safe to include regardless | | A2 | `api/admin.py` does not currently import `time` | Common Pitfalls 5 | If wrong, no change needed — planner should verify with grep | **If this table is empty:** Not applicable — A1 and A2 are documented above. --- ## Open Questions (RESOLVED) 1. **Does `test_auth_deps.py` need a FakeRedis fixture?** - What we know: `test_auth_deps.py` creates a minimal test app that calls `get_current_user`. After this phase, `get_current_user` will call `request.app.state.redis`. The minimal app has no lifespan handler. - What's unclear: Whether the existing `auth_client` fixture sets `app.state.redis` anywhere. - Recommendation: Planner should add `test_app.state.redis = FakeRedis()` in `make_test_app()` or in the fixture. No Redis key will be set so all existing tests continue to pass. - **RESOLVED:** Plan 01 Task 1 adds `test_app.state.redis = FakeRedis()` in `make_test_app()`. FakeRedis is imported from `tests.test_auth_api`. 2. **Should `_USER_NBF_TTL` be a constant in `config.py` or a module constant in `api/auth.py`?** - What we know: `access_token_expire_minutes` is already in `config.py`; TTL = `access_token_expire_minutes * 60`. - What's unclear: Whether the user prefers `settings.access_token_expire_minutes * 60` (derived at runtime) or a hardcoded `900`. - Recommendation: Use `settings.access_token_expire_minutes * 60` in all write sites so that if the TTL ever changes in config, the NBF TTL stays synchronized automatically. This is slightly cleaner than a separate constant. - **RESOLVED:** Plan 03 Tasks 1-2 use `settings.access_token_expire_minutes * 60` inline in all four write sites. No separate TTL constant extracted. --- ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Redis (`app.state.redis`) | user_nbf writes and reads | Yes (runtime via Docker Compose; FakeRedis in tests) | redis>=4.6.0 | Fail-open (D-04) | | PyJWT | `create_access_token`, `decode_access_token` | Yes | 2.13.0 [VERIFIED: runtime] | — | | Python `uuid` stdlib | `str(uuid.uuid4())` for jti | Yes (stdlib) | always available | — | | Python `time` stdlib | `int(time.time())` for user_nbf value | Yes (stdlib, needs `import time`) | always available | — | **Missing dependencies with no fallback:** None. --- ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | pytest + pytest-asyncio | | Config file | `backend/pytest.ini` (or inline `pyproject.toml` — check existing) | | Quick run command | `pytest tests/test_auth_deps.py tests/test_auth_api.py -v` | | Full suite command | `pytest -v` | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | SEC (JTI) | Every access token contains a `jti` UUID claim | Unit | `pytest tests/test_task2_auth_service.py -k jti -v` | Wave 0 stub | | SEC (NBF-write) | change_password writes user_nbf Redis key | Integration | `pytest tests/test_auth_api.py -k nbf -v` | Wave 0 stub | | SEC (NBF-write) | enable_totp writes user_nbf Redis key | Integration | `pytest tests/test_auth_api.py -k nbf -v` | Wave 0 stub | | SEC (NBF-write) | disable_totp writes user_nbf Redis key | Integration | `pytest tests/test_auth_api.py -k nbf -v` | Wave 0 stub | | SEC (NBF-write) | admin deactivation writes user_nbf Redis key | Integration | `pytest tests/test_admin_api.py -k nbf -v` | Wave 0 stub | | SEC (NBF-check) | Token with iat before user_nbf returns 401 | Integration | `pytest tests/test_auth_deps.py -k nbf -v` | Wave 0 stub | | SEC (fail-open) | Redis error in NBF check does not block request | Unit | `pytest tests/test_auth_deps.py -k failopen -v` | Wave 0 stub | | SEC (NBF-check) | Token with iat after user_nbf is accepted | Integration | `pytest tests/test_auth_deps.py -k nbf_after -v` | Wave 0 stub | ### Sampling Rate - **Per task commit:** `pytest tests/test_auth_deps.py tests/test_auth_api.py tests/test_admin_api.py -v` - **Per wave merge:** `pytest -v` (full suite) - **Phase gate:** Full suite green before `/gsd:verify-work` ### Wave 0 Gaps - [ ] `tests/test_auth_api.py` — add NBF-write tests for change_password, enable_totp, disable_totp - [ ] `tests/test_auth_deps.py` — add NBF-check tests (iat < nbf → 401, iat > nbf → pass, Redis error → pass); add FakeRedis to `auth_client` fixture or `make_test_app()` - [ ] `tests/test_admin_api.py` — add NBF-write test for deactivation handler - [ ] `tests/test_task2_auth_service.py` — add JTI presence test for `create_access_token` *(Existing 34 auth tests in `test_auth_deps.py` and `test_auth_api.py` must remain green with zero regressions.)* --- ## Security Domain ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | Yes | user_nbf key closes post-revocation window | | V3 Session Management | Yes | user_nbf + existing refresh revocation = complete session invalidation | | V4 Access Control | No | No new access control decisions | | V5 Input Validation | No | No new user input | | V6 Cryptography | No | jti is UUID (not a secret); no new crypto | ### Known Threat Patterns for JWT + Redis stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | Stolen access token replay after deactivation | Elevation of Privilege | user_nbf Redis key blocks iat-predating tokens (this phase) | | Redis outage used to bypass revocation | Denial of Service / EoP | Fail-open (D-04) — availability preferred over blocking; known and accepted trade-off | | user_nbf key TTL mismatch | Elevation of Privilege | Derive TTL from `settings.access_token_expire_minutes * 60` to stay synchronized | | HTTPException swallowed by fail-open catch | Elevation of Privilege | Explicit `except HTTPException: raise` guard before broad `except Exception` | --- ## Sources ### Primary (HIGH confidence) - PyJWT 2.13.0 — runtime verified: `iat` decoded as `int`, `jti` custom claim encoded verbatim - `backend/services/auth.py` — TOTP replay prevention pattern (lines 288-296) as direct template for user_nbf - `backend/api/auth.py` — `request.app.state.redis` access pattern (lines 197, 566) - `backend/deps/auth.py` — `get_current_user` signature and exception handling structure - `backend/config.py` — `access_token_expire_minutes: int = 15` confirms 900s TTL - `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md` — locked decisions D-01 through D-06 ### Secondary (MEDIUM confidence) - `backend/services/auth.py::check_hibp` — fail-open exception handling template - `backend/api/admin.py:351` — deactivation handler structure for D-05 write site - `backend/tests/test_auth_api.py::FakeRedis` — test infrastructure reusable for Phase 7.2 tests ### Tertiary (LOW confidence) - None — all claims verified against codebase or runtime. --- ## Metadata **Confidence breakdown:** - Standard stack: HIGH — no new packages; all libraries verified in runtime - Architecture: HIGH — patterns directly copied from existing codebase (TOTP replay, HIBP fail-open, redis access) - Pitfalls: HIGH — derived from direct code inspection of exception handling and test infrastructure - Test gaps: HIGH — confirmed by reading test files; FakeRedis missing from auth_deps fixture is a real gap **Research date:** 2026-06-05 **Valid until:** Stable (no fast-moving dependencies; Python stdlib and PyJWT 2.x API is stable)