# Flow F5 — JWT bearer validation > Cross-cutting flow that runs on every `[Authorize]` request. **ECDSA-SHA256 asymmetric validation against public keys cached from `admin`'s JWKS endpoint.** `admin` is contacted once at startup (and on JWKS refresh) for the JWKS document; subsequent request-path validation is local and does not call `admin`. ## Description ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer ` headers using public ECDSA keys cached locally from `admin`'s JWKS endpoint. On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime / `iss` / `aud` / `alg` failure → `401`. On valid token but missing required permission claim → `403`. Both `iss` and `aud` are validated against the resolved `JWT_ISSUER` / `JWT_AUDIENCE` values; the signing algorithm is pinned to `EcdsaSha256` (see `05_identity` § Implementation Details for the rationale). ## Preconditions - `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` are all resolved at startup via `ConfigurationResolver.ResolveRequiredOrThrow`. Any missing value aborts startup before the host is built. - `AddJwtAuth(builder.Configuration)` was called during `Program.cs` startup (F6); this also wired the `ConfigurationManager` against the resolved JWKS URL. - For the **first** protected request after process start, the cached JWKS is empty; the `IssuerSigningKeyResolver` synchronously fetches it from `admin`. After that fetch, subsequent requests use the cached keys until the manager's next refresh tick. ## Sequence Diagram ```mermaid sequenceDiagram autonumber participant Client as UI / Operator API client participant Pipeline as ASP.NET Pipeline participant Handler as JwtBearerHandler participant Resolver as IssuerSigningKeyResolver participant Mgr as ConfigurationManager participant Admin as admin (JWKS endpoint) participant Policy as Auth policy "FL" participant Ctrl as Feature Controller participant Errs as 06_http_conventions Client->>Pipeline: HTTP request + Authorization: Bearer Pipeline->>Errs: enter ErrorHandlingMiddleware Errs->>Handler: hand off (anonymous endpoints skip this) Handler->>Handler: parse token header — check alg ∈ ValidAlgorithms (EcdsaSha256) alt alg not in pin list Handler-->>Client: 401 Unauthorized (algorithm rejected) else alg OK Handler->>Resolver: resolve signing key for kid Resolver->>Mgr: GetConfigurationAsync(...).GetAwaiter().GetResult() alt JWKS not cached yet Mgr->>Admin: GET /.well-known/jwks.json (HTTPS, RequireHttps=true) Admin-->>Mgr: JsonWebKeySet Mgr->>Mgr: cache JWKS, schedule next refresh else JWKS cached Mgr-->>Resolver: cached JsonWebKeySet end Resolver-->>Handler: signing keys matching kid (or all keys if kid empty) Handler->>Handler: verify ECDSA-SHA256 signature alt Signature invalid Handler-->>Client: 401 Unauthorized else Signature valid Handler->>Handler: validate iss == JWT_ISSUER, aud == JWT_AUDIENCE, exp (ClockSkew = 30s) alt iss/aud mismatch OR token expired Handler-->>Client: 401 Unauthorized else Claims OK Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL") alt Claim missing or != "FL" Policy-->>Client: 403 Forbidden else permissions=FL Policy-->>Ctrl: forward to controller action Ctrl-->>Client: business response end end end end ``` ## Flowchart ```mermaid flowchart TD Start([Incoming request]) --> AnonEP{Endpoint requires auth?} AnonEP -->|no| Forward([Forward to controller]) AnonEP -->|yes| Header{Authorization: Bearer present?} Header -->|no| Unauth1([401 Unauthorized]) Header -->|yes| AlgPin{alg in ValidAlgorithms — EcdsaSha256?} AlgPin -->|no| UnauthAlg([401 Unauthorized — algorithm rejected]) AlgPin -->|yes| Cache{JWKS cached?} Cache -->|no| Fetch[HTTPS GET JWT_JWKS_URL → cache] Cache -->|yes| Sig{ECDSA-SHA256 signature valid for kid?} Fetch --> Sig Sig -->|no| Unauth2([401 Unauthorized]) Sig -->|yes| Iss{iss == JWT_ISSUER?} Iss -->|no| UnauthIss([401 Unauthorized — issuer mismatch]) Iss -->|yes| Aud{aud == JWT_AUDIENCE?} Aud -->|no| UnauthAud([401 Unauthorized — audience mismatch]) Aud -->|yes| Life{Lifetime valid? ClockSkew=30s} Life -->|no| Unauth3([401 Unauthorized — expired]) Life -->|yes| BuildPrincipal[Build ClaimsPrincipal] BuildPrincipal --> Policy{permissions claim == FL?} Policy -->|no| Forbid([403 Forbidden]) Policy -->|yes| Forward ``` ## Data Flow | Step | From | To | Data | Format | |------|------|----|------|--------| | 1 | Client | Pipeline | `Authorization: Bearer ` header | HTTP header | | 2 | `JwtBearerHandler` | `ConfigurationManager` | request for cached `JsonWebKeySet` (or refresh) | in-process | | 3 | `HttpDocumentRetriever` (on cold cache) | `admin` | `GET /.well-known/jwks.json` over HTTPS | HTTP | | 4 | `admin` | `HttpDocumentRetriever` | JWKS JSON document | application/json | | 5 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token | | 6 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` (with `iss`, `aud`, `permissions`, …) | .NET principal object | | 7 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag | | 8 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) | ## Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Missing `Authorization` header on `[Authorize]` route | `JwtBearerHandler` | Header absent | `401`. Client must obtain a token from `admin` | | Malformed JWT | `JwtBearerHandler` | Token parse failure | `401` | | Token header `alg` not in `[EcdsaSha256]` (e.g. forged `alg: HS256`) | `JwtBearerHandler` | Algorithm pin check | `401`. Pin defends against the HS256-confusion attack — see `05_identity` Caveats #6 | | Signature mismatch (wrong key, key not yet published, key rotated out) | `JwtBearerHandler` | ECDSA verify fails | `401`. Recovery: ensure `admin` published the corresponding `kid` in JWKS; on rotation the cache picks up the new keys at the next refresh tick | | Signing `kid` not in cached JWKS | `IssuerSigningKeyResolver` | No matching key in current cache | `401`. The manager refreshes on its default schedule; a new `kid` becomes available there | | `iss` claim ≠ `JWT_ISSUER` | `JwtBearerHandler` | `ValidateIssuer = true` | `401`. Tokens issued by a different `iss` (e.g. another suite environment) are rejected | | `aud` claim ≠ `JWT_AUDIENCE` | `JwtBearerHandler` | `ValidateAudience = true` | `401`. Tokens minted for a different audience (e.g. `admin` itself, or another backend) are rejected | | Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 30s`) | `401`. Tight 30-second skew — caller may experience earlier expiration than under the .NET default of 5 minutes (or the prior 1-minute setting) | | `permissions` claim missing or wrong value | Policy `"FL"` evaluator | claim lookup | `403` | | `admin` unreachable on first JWKS fetch | `HttpDocumentRetriever` | `HttpRequestException` propagated through `IssuerSigningKeyResolver` | First protected request fails 500 (handler exception → `06_http_conventions` global handler). Subsequent requests retry on the next refresh tick. **Operationally**: ensure `admin` is reachable from every edge device that authenticates against it | | `JWT_JWKS_URL` is plain HTTP | Startup (`HttpDocumentRetriever { RequireHttps = true }`) | URL scheme check at retrieve time | Service fails to validate any request; symptom is `InvalidOperationException` on JWKS fetch. **Fix**: set `JWT_JWKS_URL` to an `https://` URL | ## Performance Expectations | Metric | Target | Notes | |--------|--------|-------| | Validation latency (warm cache) | sub-millisecond typical | Pure ECDSA verify + claim lookup; no I/O | | Validation latency (cold cache, first request) | one-time JWKS fetch cost (single-digit ms on local LAN) | Synchronous `GetAwaiter().GetResult()` blocks the worker thread until the fetch returns | | Throughput | bounded by request throughput | No back-pressure; the cached JWKS handles all subsequent requests until refresh | | JWKS refresh frequency | `ConfigurationManager` default (5 minutes minimum) | Matches admin's `Cache-Control: public, max-age=3600` so a forced refresh always sees fresh content | ## Notes on key rotation Unlike the legacy shared-secret model, JWKS rotation does **NOT** require a coordinated redeploy of every consumer. When `admin` rotates keys it publishes the new key alongside the old `kid` (or with a new `kid`). The next refresh tick on every consumer's `ConfigurationManager` picks up the new public key. Old tokens signed under the previous `kid` remain valid until expiry as long as the old `kid` is still published. This is a major operational improvement over the previous "rotate `JWT_SECRET`, re-deploy every backend, force every user to re-login" sequence.