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, so a client cannot spoof them:
| 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.- 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, so a managed API must not add its own.
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 | The service ships its own CORS middleware (doubled headers), or its workload.yaml lacks visibility: external |
Remove the middleware; confirm visibility: external. |
| 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. |