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-Idmissing 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-webappships 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.
groupsis the one that matters: a token issued without it (aclient_credentialstoken, or a user in no groups) leavesX-User-Groupscaller-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 onX-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 beWHERE id = ? AND user_id = ?. The same pairing applies to updates and deletes, and a query that matches nothing is a404, not a500.
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. |