2026-05-30 — Strict JWT validation (azp/appid + shorter cache TTL)¶
Motivation¶
Security audit items P1 #6 and P1 #9:
- #6 (
azp/appidenforcement) — the bearer-token validator only checkedaud,iss,tid,exp,iat, andoid. Any other app registration inside the same tenant that minted a token for our API audience (rare but possible — e.g. a misconfigured downstream app with admin consent for our API) would be accepted as long as it had the rightaud. The audit asked for pinning the token to the authorised party. - #9 (claims cache TTL) —
_CLAIMS_CACHE_MAX_TTL_SECONDSwas 300 s (5 min). If a SPA's admin consent is revoked, the previous decision to grant access could be cached for that full window. Audit asked for a tighter cap (≤ 60 s) in the hardened profile.
User-facing change¶
- Default behaviour unchanged. Per charter §12a Rule 4 the new
guards are gated behind
STRICT_JWT=true. When the flag is unset, validation logic and cache TTL behaviour are byte-for-byte identical to today. - When
STRICT_JWT=true: - Tokens must carry an
azp(AAD v2) orappid(AAD v1) claim whose value is inJWT_ALLOWED_APPIDS. The allowlist defaults to the configuredAPI_CLIENT_ID, so single-app deployments do not need to setJWT_ALLOWED_APPIDSexplicitly. Operators with separate SPA + API app registrations setJWT_ALLOWED_APPIDS=<spa-id>[,<other-id>]. - The claims cache TTL is capped at 60 s (down from 300 s). The 25 s polling cycle the dashboard uses is unaffected — the typical request still gets a cache hit — but a revoked SPA stops being accepted within a minute.
API / IaC diff summary¶
api/auth.py¶
- New module-level constants:
_CLAIMS_CACHE_STRICT_TTL_SECONDS = 60_STRICT_JWT_ENV = "STRICT_JWT"_JWT_ALLOWED_APPIDS_ENV = "JWT_ALLOWED_APPIDS"- New helpers:
_is_strict_jwt()— re-reads the env var on every call so tests canmonkeypatch.setenvwithout reloading the module._claims_cache_ttl_cap()— returns 60 s under strict, 300 s otherwise._jwt_allowed_appids(api_client_id)— defaults to{api_client_id}ifJWT_ALLOWED_APPIDSis unset._claims_cache_putnow uses_claims_cache_ttl_cap()instead of the bare_CLAIMS_CACHE_MAX_TTL_SECONDSconstant._validate_tokenenforces theazp/appidcheck immediately after theoidclaim presence check, gated by_is_strict_jwt(). The legacy path is untouched (constant fast-fail at the first guard).
Infrastructure¶
- No Bicep change.
STRICT_JWTandJWT_ALLOWED_APPIDSare not added to the Container App template yet — flipping the gate is a separate PR after a full release-cycle soak per charter §12a Rule 4.
Tests¶
- New
api/tests/test_strict_jwt.py(10 tests) covers both the ON and the OFF paths, the lazy env read, theazp/appidprecedence, the unauthorized-appid rejection, theJWT_ALLOWED_APPIDSoverride, the TTL cap under strict, and the cache-put TTL ceiling effect.
Validation evidence¶
$ uv run pytest -q api/tests/test_strict_jwt.py
.......... [100%]
10 passed in 2.39s
$ uv run pytest -q api/tests/test_security_audit_4_8.py \
api/tests/test_persona_matrix.py \
api/tests/test_sse_ticket_binding.py
74 passed in 5.76s
$ uv run pytest -q api/tests
2109 passed, 3 skipped in 34.25s
$ uv run ruff check api/auth.py api/tests/test_strict_jwt.py
All checks passed!
No new deployment required. Local-debug + dev-bypass flows are
unaffected because AUTH_DEV_BYPASS=true short-circuits before
_validate_token runs.
Hardening discipline (§12a)¶
- In scope: jwt
- RBAC change is single-PR safe (no role narrowed) OR labelled
phase-1 of 2/phase-2 of 2 (see #…)— N/A, no RBAC change - Persona Matrix tests pass for owner / contributor / reader / dev_bypass — full suite green (2109 passed)
- Reader allowlist unchanged OR split-PR link: N/A
- Capability Probe passes locally — N/A for code-only change (no Bicep change, no role change, postprovision unchanged)
- New guard ships default-OFF behind
STRICT_*/ENFORCE_*env var —STRICT_JWTdefaults to OFF and the unset path is covered bytest_strict_jwt_defaults_offandtest_strict_jwt_off_does_not_shorten_cache - No
Depends(require_caller)added to an SSE event stream - Change note (this file) summarises persona impact: Reader /
Contributor / Owner who already authenticate successfully today
see no change while
STRICT_JWTis unset (the default). Once the gate is flipped, the only requests rejected are tokens issued by a non-allowlisted app — those callers would not have been recognised personas of our SPA in the first place.