2 plans (2 waves): Wave 0 xfail stubs for test_auth_fgp.py (4 FGP tests), Wave 1 production implementation (_compute_fgp helper, create_access_token fgp claim, get_current_user fgp validation, login/refresh call-site updates, test promotion) + version bump to 0.1.3. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
24 KiB
Phase 7.4: Security — Token Fingerprinting / Token Binding - Research
Researched: 2026-06-06 Domain: JWT access token fingerprinting via HMAC-SHA256 claim Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Use
settings.secret_key(SECRET_KEYenv var) as the HMAC key — no new env var. - D-02: Missing
User-AgentorAccept-Languagefalls back to"". Thefgpclaim is always computed and always validated — no skip path. - D-03: Fingerprint mismatch raises HTTP 401 immediately with
detail="Token fingerprint mismatch". No soft mode. - D-04: Helper
_compute_fgp(user_agent: str, accept_lang: str) -> strdefined inbackend/services/auth.py(module-private). Returnshmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), sha256).hexdigest()[:16]. - D-05:
create_access_token(user_id, role)gainsuser_agent: str = ""andaccept_lang: str = ""parameters. Default empty strings preserve backward compatibility with existing tests. - D-06: fgp validation in
get_current_userplaced after theuser_nbfblock. Iffgp_claimis empty (old token without claim) → allow (graceful migration). If non-empty and mismatch → 401.
Claude's Discretion
None stated.
Deferred Ideas (OUT OF SCOPE)
- Key rotation process for
SECRET_KEY - Per-request fingerprint rotation / device key pinning </user_constraints>
Summary
Phase 7.4 is a pure backend code change with no schema migrations, no new endpoints, and no frontend work. The change set is small and surgically scoped to three files:
backend/services/auth.py— add_compute_fgphelper, extendcreate_access_tokensignature, embedfgpclaim in payload.backend/deps/auth.py— add fgp validation block after theuser_nbfcheck.backend/api/auth.py— pass request headers tocreate_access_tokenat the two call sites (login and refresh handlers).backend/tests/test_auth_fgp.py— new test file with four test cases following Phase 7.2/7.3 patterns.
No new dependencies. No new environment variables. hashlib is already imported in services/auth.py (line 21). hmac is already imported (line 22). request.headers is already accessible in both call sites and in get_current_user. The conftest _patch_es256_test_keys autouse fixture already covers all tests.
Primary recommendation: Three targeted edits to existing files plus one new test file — total diff should be under 60 lines of production code.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| fgp claim creation | API / Backend (service layer) | — | _compute_fgp in services/auth.py; pure Python, no FastAPI coupling |
| fgp claim embedding | API / Backend (service layer) | — | create_access_token builds the JWT payload |
| fgp claim validation | API / Backend (dependency layer) | — | get_current_user in deps/auth.py validates on every authenticated request |
| Header extraction | API / Backend (router/dep layer) | — | request.headers.get(...) idiomatic FastAPI pattern; already in both callers |
| HMAC key storage | Config / env | — | settings.secret_key — no new field needed |
Standard Stack
No new packages. All required modules are already installed and imported.
Already Available
| Module | Location | Already Imported? | Notes |
|---|---|---|---|
hmac |
stdlib | Yes — services/auth.py:22 [VERIFIED: read file] |
Use hmac.new(...) (Python stdlib name is hmac.new) |
hashlib |
stdlib | Yes — services/auth.py:21 [VERIFIED: read file] |
hashlib.sha256 used as digestmod |
hmac.compare_digest |
stdlib | Already used — services/auth.py:419 [VERIFIED: read file] |
Same pattern as backup code comparison |
request.headers |
FastAPI Request |
request: Request already in get_current_user signature [VERIFIED: read file] |
Pattern: request.headers.get("User-Agent", "") |
Installation
No new packages to install.
Package Legitimacy Audit
Not applicable — no new packages.
Architecture Patterns
System Architecture Diagram
POST /api/auth/login POST /api/auth/refresh
| |
| request.headers.get(...) | request.headers.get(...)
v v
services/auth.create_access_token(user_id, role, user_agent, accept_lang)
|
| _compute_fgp(user_agent, accept_lang) -> hexdigest[:16]
| payload["fgp"] = fgp_value
| jwt.encode(payload, private_pem, algorithm="ES256")
v
JWT access token (fgp claim embedded)
Every authenticated request:
|
v
deps/auth.get_current_user(request, credentials, session)
|
| decode_access_token -> payload
| user_nbf check (Phase 7.2 block, lines 62–85)
|
| [NEW] fgp_claim = payload.get("fgp", "")
| if fgp_claim:
| fgp_actual = _compute_fgp(request.headers.get(...), request.headers.get(...))
| if not hmac.compare_digest(fgp_claim, fgp_actual):
| raise HTTP 401 "Token fingerprint mismatch"
|
v
uuid.UUID(payload["sub"]) -> User lookup
Recommended Project Structure
No new files in src/. One new test file:
backend/
├── services/auth.py # add _compute_fgp + extend create_access_token
├── deps/auth.py # add fgp validation block after user_nbf
├── api/auth.py # update 2 call sites: login + refresh
└── tests/
└── test_auth_fgp.py # new file, 4 test cases
Pattern 1: fgp helper (_compute_fgp)
What: Module-private function in services/auth.py, placed before create_access_token.
When to use: Called from create_access_token (at issuance) and referenced by get_current_user via import.
# Source: CONTEXT.md D-04
import hmac as _hmac_mod # avoid shadowing module if 'hmac' name is also used as variable
import hashlib
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint of User-Agent + Accept-Language (D-04)."""
return _hmac_mod.new(
settings.secret_key.encode(),
(user_agent + accept_lang).encode(),
hashlib.sha256,
).hexdigest()[:16]
Note on import name: hmac is already imported at line 22 of services/auth.py as import hmac. The hmac module exposes .new() directly. No rename needed — hmac.new(...) works. [VERIFIED: stdlib docs, hmac module]
Pattern 2: create_access_token signature extension
What: Add two keyword parameters with empty-string defaults to create_access_token.
Exact current signature (line 87): def create_access_token(user_id: str, role: str) -> str: [VERIFIED: read file]
Updated signature:
def create_access_token(
user_id: str,
role: str,
user_agent: str = "",
accept_lang: str = "",
) -> str:
Inside the function, add "fgp": _compute_fgp(user_agent, accept_lang) to the payload dict alongside existing claims (sub, role, typ, iat, exp, jti). [VERIFIED: payload dict at lines 93–100 of services/auth.py]
Pattern 3: fgp validation block in get_current_user
Placement: After line 85 (end of user_nbf block), before line 87 (uuid.UUID(payload["sub"])). [VERIFIED: read deps/auth.py]
Structural model — copy the user_nbf block pattern (try / except HTTPException: raise / except Exception: log + fail-open), but with a critical difference from NBF: the fgp mismatch must NOT be swallowed by the broad except. The mismatch raise is an explicit HTTPException inside the try block, which the except HTTPException: raise guard re-raises correctly.
# ── fgp check (D-06, Phase 7.4) ────────────────────────────────────────────
fgp_claim = payload.get("fgp", "")
if fgp_claim:
fgp_actual = auth_service._compute_fgp(
request.headers.get("User-Agent", ""),
request.headers.get("Accept-Language", ""),
)
if not hmac.compare_digest(fgp_claim, fgp_actual):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token fingerprint mismatch",
headers={"WWW-Authenticate": "Bearer"},
)
# ── end fgp check ───────────────────────────────────────────────────────────
Note: _compute_fgp is a module-private function in services/auth.py. deps/auth.py already imports from services import auth as auth_service (line 32). Calling auth_service._compute_fgp(...) follows the existing import pattern. Alternatively, the function can be made public (no underscore), but keeping it private signals it is not part of the public service API. Either approach is fine — the planner should decide. [ASSUMED: naming convention choice]
Pattern 4: Caller updates in api/auth.py
Login handler (line 290): [VERIFIED: read api/auth.py]
# Before:
access_token = auth_service.create_access_token(str(user.id), user.role)
# After:
access_token = auth_service.create_access_token(
str(user.id),
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
Refresh handler (line 364): [VERIFIED: read api/auth.py]
# Before:
access_token = auth_service.create_access_token(user_id_str, user.role)
# After:
access_token = auth_service.create_access_token(
user_id_str,
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
Both handlers already have request: Request in their signature. [VERIFIED: read api/auth.py lines 192–195 and 321–325]
Anti-Patterns to Avoid
- Calling
_compute_fgpindeps/auth.pydirectly instead of viaauth_service:deps/auth.pyalready importsauth_service— use it for consistency. Do not add a second import of the function. - Wrapping the fgp
HTTPExceptionraise in a broad except: Theexcept HTTPException: raiseguard at line 81 ofdeps/auth.pyensures the intentional 401 is never swallowed. The fgp check must be structured inside the same or a separate try block with the same guard. [VERIFIED: read deps/auth.py lines 81–84] - Treating
fgp_claim == ""as a mismatch: Empty fgp_claim means the token predates this phase — it must be allowed (graceful migration, D-06). - Using
==instead ofhmac.compare_digest: Constant-time comparison is mandatory for all token comparisons (SEC-06, CLAUDE.md).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Constant-time string comparison | Custom loop | hmac.compare_digest |
SEC-06 — timing attack prevention; already established in codebase |
| HMAC computation | Manual SHA-256 + XOR | hmac.new(..., hashlib.sha256) |
Correct keyed-HMAC semantics; raw SHA-256 is not an HMAC |
Common Pitfalls
Pitfall 1: fgp mismatch swallowed by broad except
What goes wrong: The fgp HTTPException is raised inside the try block but the broad except Exception catches it before except HTTPException: raise runs — only if the guard order is reversed.
Why it happens: Python evaluates except clauses in order; if except Exception appears before except HTTPException, the HTTPException is matched and swallowed.
How to avoid: The existing pattern in deps/auth.py already has except HTTPException: raise before except Exception (lines 81–84). Maintain this order. [VERIFIED: read file]
Warning signs: fgp mismatch returns 200 or 500 instead of 401.
Pitfall 2: test fixtures call create_access_token without new params — will fgp validation break?
What goes wrong: After Phase 7.4, all test-issued tokens will carry fgp computed from ("", "") because user_agent="" and accept_lang="" are the defaults. Test requests sent without User-Agent/Accept-Language headers will also compute _compute_fgp("", ""). These will match — no test breakage.
Why it happens: Default parameters make the no-arg call create_access_token(user_id, role) emit fgp = hmac("" + "")[:16]. An unauthenticated test request with no headers also computes the same value. Match succeeds.
How to avoid: No action required for existing tests. The test for "wrong fgp" must explicitly issue a token with headers A then make a request with different headers B.
Warning signs: All existing auth tests still pass (expected); they should not require changes.
Pitfall 3: hmac.new vs hmac.HMAC
What goes wrong: hmac.new(...) is the correct stdlib constructor. Attempting hmac.HMAC(...) directly is not the intended public API.
How to avoid: Use hmac.new(key, msg, digestmod) exactly as specified in D-04.
Warning signs: AttributeError: module 'hmac' has no attribute 'HMAC' (HMAC is the class, new() is the factory).
Pitfall 4: _compute_fgp visibility across module boundary
What goes wrong: deps/auth.py calls auth_service._compute_fgp(...). Python name mangling does NOT apply to module-level _ names — they are accessible from other modules, just conventionally private. This will work fine.
How to avoid: No special handling needed; the underscore is a convention, not an enforcement. [VERIFIED: Python docs]
Pitfall 5: conftest fixtures will produce fgp-bearing tokens after Phase 7.4
What goes wrong: After the change, create_access_token(str(user_id), "user") in conftest (and all test files) will embed fgp = hmac("", "")[:16]. Requests made by auth_client or async_client without User-Agent/Accept-Language headers compute the same fingerprint → 200. This is the intended behaviour.
Warning signs: If a test explicitly sets User-Agent in its request headers, the token issued by the fixture (with user_agent="") will mismatch the request headers → 401. This would only affect tests that set custom User-Agent headers, which currently none do. [VERIFIED: grep of conftest and test_auth_deps.py]
Code Examples
Complete fgp Helper (from CONTEXT.md D-04)
# Source: CONTEXT.md D-04 (canonical)
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
return hmac.new(
settings.secret_key.encode(),
(user_agent + accept_lang).encode(),
hashlib.sha256,
).hexdigest()[:16]
Test Case Structure (4 required by CONTEXT.md)
# Source: CONTEXT.md §Specific Ideas
# Test 1 — correct fgp → 200
token = create_access_token(user_id, "user", user_agent="Mozilla/5.0", accept_lang="en")
resp = await auth_client.get(
"/test/me",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "Mozilla/5.0",
"Accept-Language": "en",
},
)
assert resp.status_code == 200
# Test 2 — wrong fgp → 401
token = create_access_token(user_id, "user", user_agent="Mozilla/5.0", accept_lang="en")
resp = await auth_client.get(
"/test/me",
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "different-agent", # mismatch
"Accept-Language": "en",
},
)
assert resp.status_code == 401
assert resp.json()["detail"] == "Token fingerprint mismatch"
# Test 3 — token without fgp claim → 200 (migration grace)
# Manually craft a token without "fgp" in the payload
payload_no_fgp = {
"sub": str(user_id), "role": "user", "typ": "access",
"iat": ..., "exp": ..., "jti": str(uuid.uuid4()),
}
token_no_fgp = jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")
resp = await auth_client.get("/test/me", headers={"Authorization": f"Bearer {token_no_fgp}"})
assert resp.status_code == 200
# Test 4 — missing User-Agent → empty-string binding works
token = create_access_token(user_id, "user") # user_agent="", accept_lang=""
resp = await auth_client.get(
"/test/me",
headers={"Authorization": f"Bearer {token}"},
# No User-Agent, no Accept-Language — both default to ""
)
assert resp.status_code == 200
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Access tokens had no device binding | Tokens carry fgp HMAC claim |
Phase 7.4 | Stolen tokens can only be replayed from same UA/language context |
Detailed File Change Map
| File | Change | Lines affected (approx) |
|---|---|---|
backend/services/auth.py |
Add _compute_fgp helper (6 lines) before create_access_token; extend create_access_token signature (+2 params); add "fgp" to payload dict (+1 line) |
~10 lines added |
backend/deps/auth.py |
Add fgp validation block after line 85 | ~9 lines added |
backend/api/auth.py |
Update login call site (line 290) and refresh call site (line 364) to pass headers | ~8 lines changed |
backend/tests/test_auth_fgp.py |
New file: 4 test functions + 1 helper fixture | ~100 lines new |
Total production code change: ~27 lines. Well within the ≤50 line bug-fix ceiling for any single plan.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | pytest + pytest-asyncio |
| Config file | backend/pytest.ini (or pyproject.toml) |
| Quick run command | pytest tests/test_auth_fgp.py -v |
| Full suite command | pytest -v |
Phase Requirements → Test Map
| Behaviour | Test ID | Test Type | Automated Command |
|---|---|---|---|
| Token with correct fgp → 200 | FGP-01 | integration | pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x |
| Token with wrong fgp → 401 | FGP-02 | integration | pytest tests/test_auth_fgp.py::test_fgp_mismatch_returns_401 -x |
| Token without fgp claim → 200 (migration grace) | FGP-03 | integration | pytest tests/test_auth_fgp.py::test_no_fgp_claim_allowed -x |
| Missing headers → empty-string binding works | FGP-04 | integration | pytest tests/test_auth_fgp.py::test_missing_headers_empty_string_binding -x |
Sampling Rate
- Per task commit:
pytest tests/test_auth_fgp.py tests/test_auth_deps.py -x - Per wave merge:
pytest -v(all 411+ tests) - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
tests/test_auth_fgp.py— new file, covers FGP-01..04. Must be created in Wave 0 as xfail stubs before implementation.
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | yes | hmac.compare_digest for all fingerprint comparison |
| V3 Session Management | yes | fgp binds access token to originating client context |
| V5 Input Validation | yes | Headers extracted via .get(..., "") — no injection possible; HMAC treats as opaque bytes |
| V6 Cryptography | yes | hmac.new(..., hashlib.sha256) — standard keyed HMAC, not custom |
Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Stolen access token replay from different device | Spoofing / Elevation | fgp mismatch → HTTP 401 |
| Timing attack on fingerprint comparison | Information Disclosure | hmac.compare_digest (constant-time) |
| Empty-string collision (all CLI clients share one fingerprint) | Spoofing | Accepted by D-02 — deliberate design; equivalent to pre-Phase-7.4 security for CLI clients |
Open Questions (RESOLVED)
-
_compute_fgpvisibility: private (_compute_fgp) vs public (compute_fgp)?- What we know:
deps/auth.pyneeds to call it; it is currently planned as_compute_fgp(module-private by convention). - What's unclear: The CONTEXT.md specifies
_compute_fgp. Callingauth_service._compute_fgpfromdeps/auth.pyworks but is unconventional. - Recommendation: Keep the underscore per D-04;
auth_service._compute_fgpis acceptable in this tightly coupled internal boundary. Alternatively, expose ascompute_fgp(no underscore) for cleaner inter-module use — either is consistent with the codebase. The planner should pick one and document it. [ASSUMED: naming decision] - RESOLVED: Keep private (
_compute_fgp) per D-04. The underscore convention signals it is not part of the public service API.auth_service._compute_fgp(...)indeps/auth.pyis acceptable at this tightly coupled internal boundary.
- What we know:
-
Should the fgp check be inside the existing user_nbf try block or in its own try block?
- What we know: The user_nbf block is a try/except with fail-open on Redis errors (D-04 of Phase 7.2). The fgp check has no external I/O — it cannot fail due to infrastructure.
- What's unclear: Structurally, putting fgp inside the same try block would work, but it conflates two different concerns. A separate, unconditional if-block (no try/except needed) is cleaner.
- Recommendation: Place fgp as a plain
if fgp_claim:block AFTER the user_nbf try/except ends (after line 85). No try/except wrapper needed for fgp — it is pure computation, not I/O. [ASSUMED] - RESOLVED: Place as a standalone
if fgp_claim:block AFTER the user_nbf try/except ends (after line 85). No try/except wrapper — pure computation, no I/O. The existingexcept HTTPException: raiseguard in any wrapping try block would still catch the mismatch 401 correctly, but keeping it outside is cleaner.
Environment Availability
Step 2.6: SKIPPED — this phase is a pure backend code change with no external dependencies beyond the existing Python stdlib and already-installed PyJWT. No new tools, services, or runtimes required.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | _compute_fgp naming convention (underscore vs public) |
Open Questions #1 | Cosmetic only — either works at runtime |
| A2 | fgp check is a plain if-block, not wrapped in try/except | Open Questions #2 | Cosmetic only — try/except around pure computation is harmless but unnecessary |
Sources
Primary (HIGH confidence)
backend/services/auth.py— verified exact current signatures, imports, payload structure, existinghmac.compare_digestusage [VERIFIED: read file]backend/deps/auth.py— verifiedget_current_userstructure, user_nbf block lines 62–85,request: Requestin signature,except HTTPException: raiseguard [VERIFIED: read file]backend/api/auth.py— verified login call site (line 290), refresh call site (line 364), both haverequest: Request[VERIFIED: read file]backend/config.py— verifiedsecret_key: str = "CHANGEME"at line 31 [VERIFIED: read file]backend/tests/conftest.py— verified autouse_patch_es256_test_keysfixture,auth_user/admin_userfixtures callcreate_access_token(str(user_id), role)[VERIFIED: read file]backend/tests/test_auth_deps.py— verified Phase 7.2 NBF test pattern (FakeRedis, make_test_app,_create_userhelper) [VERIFIED: read file]backend/tests/test_auth_es256.py— verified Phase 7.3 test pattern (autousees256_keysfixture, test naming convention) [VERIFIED: read file]- CONTEXT.md — locked decisions D-01 through D-06 [VERIFIED: read file]
Secondary (MEDIUM confidence)
grep -rn "create_access_token"output — confirmed only two production call sites exist (api/auth.py:290login andapi/auth.py:364refresh); all other hits are test files [VERIFIED: bash grep]- Python stdlib
hmacmodule —hmac.new(key, msg, digestmod)is the correct constructor [CITED: docs.python.org/3/library/hmac.html]
Metadata
Confidence breakdown:
- File change map: HIGH — read all target files directly
- Standard stack: HIGH — no new packages; all modules verified present
- Test patterns: HIGH — read Phase 7.2/7.3 test files directly
- Architecture: HIGH — CONTEXT.md fully specifies implementation
- Pitfalls: HIGH — derived from direct code inspection
Research date: 2026-06-06 Valid until: N/A — this is a single-session phase with no external dependencies