7.1 KiB
7.1 KiB
Phase 7.4: Security — Token Fingerprinting / Token Binding - Context
Gathered: 2026-06-06 Status: Ready for planning
## Phase BoundaryPhase 7.4 adds a fgp (fingerprint) claim to every issued access token. The claim is a 16-char hex prefix of HMAC-SHA256(SECRET_KEY, User-Agent + Accept-Language). In get_current_user, the fingerprint is recomputed from the incoming request headers and compared with hmac.compare_digest. A mismatch results in HTTP 401.
This limits the replay window of a stolen access token to the original device/browser context. No schema migrations, no new endpoints, no frontend changes.
## Implementation DecisionsHMAC Key
- D-01: Use
settings.secret_key(SECRET_KEYenv var) as the HMAC key. This was explicitly reserved for Phase 7.4 fingerprinting in Phase 7.3 D-03. No new env var is needed.
Missing Header Behavior
- D-02: When
User-AgentorAccept-Languageis absent, use an empty string ("") as the fallback value for that header. Thefgpclaim is always computed and always validated — there is no "skip" path. This means CLI tools, Postman, and API clients receive tokens that bind tofgp("", "")or similar, and their requests continue to work as long as they consistently send the same (possibly absent) headers.
Mismatch Enforcement
- D-03: A fingerprint mismatch raises HTTP 401 immediately with detail
"Token fingerprint mismatch". No soft/log-only mode. The protection is meaningless unless enforced.
Fingerprint Computation Function
- D-04: Define a module-level helper
_compute_fgp(user_agent: str, accept_lang: str) -> strinbackend/services/auth.py. Returnshmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), sha256).hexdigest()[:16]. Centralises the logic; bothcreate_access_tokenandget_current_usercall the same function.
create_access_token Signature Change
- D-05:
create_access_token(user_id, role)gains two new parameters:user_agent: str = ""andaccept_lang: str = "". All callers pass request headers through. The default empty string means the function signature is backward-compatible with any test that doesn't yet pass headers.
Validation in get_current_user
- D-06: After the user_nbf Redis check (Phase 7.2), add the fgp validation block. Extract
fgp_claim = payload.get("fgp", ""). Recomputefgp_actual = _compute_fgp(request.headers.get("User-Agent", ""), request.headers.get("Accept-Language", "")). Iffgp_claimis non-empty andnot hmac.compare_digest(fgp_claim, fgp_actual)→ raise HTTP 401. Iffgp_claimis empty (tokens issued before this phase), allow the request — graceful migration window.
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Token issuance — target function
backend/services/auth.pyline 87 —create_access_token(user_id, role): adduser_agent=""andaccept_lang=""params; computefgpclaim here using_compute_fgpbackend/services/auth.pyline 22 —import hmacalready present; addimport hashlibif not already there (needed forsha256digestmod)
Token validation — target function
backend/deps/auth.pyline 41 —get_current_user: add fgp validation block after theuser_nbfcheck (line 86); usesrequest.headers.get(...)(request already in signature)
Callers of create_access_token (must be updated to pass headers)
backend/api/auth.py— login handler (issues new access token after credential check)backend/api/auth.py— refresh handler (issues new access token when rotating refresh token)- Any other site that calls
create_access_token— grep forcreate_access_token(to find all callers
Config — HMAC key
backend/config.pyline 31 —secret_key: str = "CHANGEME"— this issettings.secret_key; no new field needed
CLAUDE.md security requirement
CLAUDE.md§"Login token hardening" — mandatesfgpclaim = HMAC ofUser-Agent + Accept-Language, validated on every request.planning/codebase/CONCERNS.md§"No Token Fingerprint / Token Binding" — original risk description and fix approach
Phase 7.2 pattern (user_nbf check — structural reference for placement)
.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md— fgp check must be placed AFTER the user_nbf block; follow the same fail-pattern (HTTPException guard + broad except)
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
hmacmodule already imported inservices/auth.py:22— addhashlibimport for SHA-256 digestmodrequest.headers.get("User-Agent", "")pattern is idiomatic FastAPI;request: Requestis already inget_current_user's signaturehmac.compare_digestalready used inservices/auth.py:419for backup code comparison — same pattern applies here
Established Patterns
user_nbfcheck indeps/auth.py:62–85— exact structural pattern to copy:try / except HTTPException: raise / except Exception as exc: _logger.warning(...). Note: fgp validation does NOT use fail-open (unlike NBF) — it should raise inside the try block unconditionally on mismatch, not be swallowed by a broad except.create_access_tokenpayload dict inservices/auth.py:93— add"fgp": fgp_valuealongside existingsub,role,typ,iat,exp,jticlaims
Integration Points
backend/api/auth.pylogin handler — currently callscreate_access_token(str(user.id), user.role); update to passrequest.headers.get("User-Agent", "")andrequest.headers.get("Accept-Language", ""). The login handler already receivesrequest: Request.backend/api/auth.pyrefresh handler — same update required; the refresh endpoint also hasrequest: Requestbackend/deps/auth.py:86— fgp check inserts right after theuser_nbfblock ends, before theuuid.UUID(payload["sub"])parse
</code_context>
## Specific Ideas- Helper function signature:
def _compute_fgp(user_agent: str, accept_lang: str) -> str— module-private, defined once, called from bothcreate_access_tokenandget_current_user's validation block via the auth service. - The
fgpcheck inget_current_usershould be structured so that tokens without anfgpclaim are allowed (graceful forward migration: existing logged-in sessions issued before this phase don't instantly break). Only tokens that carry anfgpclaim get it validated. - Test coverage must include: (1) token with correct fgp → 200, (2) token with wrong fgp → 401, (3) token without fgp claim → 200 (migration grace), (4) missing User-Agent → empty-string binding works.
- Key rotation for SECRET_KEY: A production key-rotation process for
SECRET_KEYwould invalidate all fgp bindings and refresh tokens simultaneously. Worth documenting in a RUNBOOK but not in scope here. - Per-request fingerprint rotation (device key pinning, stronger binding): Future enhancement — not needed for v1.
Phase: 07.4-security-token-fingerprinting-token-binding-inserted Context gathered: 2026-06-06