API Management
A service whose design sets exposesAPI.auth sits behind the platform's API gateway. The gateway terminates authentication: it validates the caller's token against the org's IDP and passes the verified identity down as headers. Your code trusts those headers and never sees a token.
Constraints
Never validate a JWT. The gateway did it already, against keys your service does not have — the signing keys, the client_id and the IDP's discovery URL are all platform-side. A service that parses or verifies tokens is duplicating the gateway and will disagree with it.
Never issue one either. No /auth/login, /auth/register, /auth/logout, or any token endpoint on any backend. The IDP owns token issuance — see thunder-authentication.
Identity arrives in headers, set by the gateway from the validated token:
| Header |
Claim |
Presence |
X-User-Id |
sub |
the caller's canonical, opaque IdP subject — always present on a protected request |
X-User-Groups |
groups |
the caller's role groups, a JSON array — present when the user is in any group |
X-User-Name |
username |
the caller's username — may be absent |
X-User-Ou |
ouHandle |
the caller's organization (multi-tenant, optional) |
thunder-authentication owns what these mean and how to authorize on them — role resolution, the directory join, and why X-User-Id is not a lookup key. Two rules are this skill's, because they are the gateway's contract:
X-User-Id missing on a protected request → 401. The gateway always sets
it when it lets a request through, so its absence means the request did not come through the gateway — a deployment fault, not an anonymous caller. Declare the header OPTIONAL in your framework and resolve it in one helper, so your service picks that status: a framework-level "required header" rejection answers 400 before your resolver runs, which makes this rule unreachable.
- Only the gateway may assert identity. A proxy in front of your service that
forwards untrusted traffic (a SPA's nginx) must clear inbound X-User-*, and must itself proxy THROUGH the gateway — react-webapp ships an asset that does both. A caller reaching your service on a lane with no gateway on it can set those headers freely.
- A claim the token does not carry is not asserted. The gateway writes a
header only when its claim is present; when it is absent the client's own value for that header is forwarded. groups is the one that matters: a token issued without it (a client_credentials token, or a user in no groups) leaves X-User-Groups caller-controlled. Treat a role decision as trustworthy only for a caller whose token actually carries the claim. A service that owns its own people records sidesteps this: its role comes from the record it stored, keyed on X-User-Id, which no caller can set (thunder-authentication).
- An authenticated caller who has no role → 403, never 401. A 401 tells the
SPA its token expired, so it restarts sign-in and loops forever. The role resolution itself is in thunder-authentication.
Own your rows by X-User-Id. It is the only stable per-caller key the gateway gives you: stamp it on every row this service creates, and gate every per-user query on it.
CORS. The api-configuration ClusterTrait attaches an Envoy CORS filter per visibility: external HTTPRoute.
Document the injected header. In the OpenAPI you author for a protected service, list X-User-Id under parameters so consumers know it is required-but-injected: the gateway adds it, clients never set it.
Implementation
Two rules, and both are mandatory in every protected handler:
- Read
X-User-Id; 401 when it is missing. Resolve it once, in one helper,
rather than re-reading the header at each call site.
- Gate every per-user query on it — both filters, always. A bare
WHERE id = ? lets a caller reach any user's row by guessing its id; it must be WHERE id = ? AND user_id = ?. The same pairing applies to updates and deletes, and a query that matches nothing is a 404, not a 500.
Express both in your stack's own idiom — its routing style, where a shared helper lives, and how a handler returns a status — following the conventions that skill already sets rather than inventing a second one here.
Role-based and directory-scoped handlers build on this — see thunder-authentication.
Calling a protected upstream
When forwarding the caller's auth to an upstream bearer API, propagate the inbound Authorization header verbatim — never re-issue or mint a token.
Pitfalls
| Symptom |
Cause |
Fix |
| CORS error in the browser when calling this API |
This service ships its own CORS middleware (doubled headers) |
Remove the middleware. |
| Every protected request 401s in tests |
Test calls carry no X-User-Id — in production the gateway sets it |
Set X-User-Id directly on the request in tests; don't try to mint a JWT. |