A trusted identity header is not an authorization decision
A reverse proxy or API gateway can inject X-User-Id, X-Forwarded-User, or a custom “trusted” identity header after authenticating at the edge. That header answers who the request claims to be. It does not answer whether that principal may read or mutate this object now.
The bug pattern
A familiar internal layout:
- The edge authenticates (OIDC, mTLS, session cookie) and sets
X-User-Id: aliceon the hop to the app. - The app trusts any request that arrives with that header — often because the service listens only on a private network or “behind the mesh.”
- Handlers load
/orders/{id}(or delete, export, approve) after parsing the header, with noauthorize(actor, action, resource)against the live policy store. - Spoofed headers from a misconfigured peer, a compromised sidecar, a debug tunnel, or a confused deputy still succeed. Revoked membership never changes the outcome for an already-injected identity.
Teams then treat “we only accept traffic from the gateway” as object authorization. Curious coworkers, buggy clients, and attackers who can reach the same hop read or change records the edge never checked.
Why trusted headers fail as authz
Identity propagation and authorization solve different problems.
- Header presence ≠ grant. Seeing
X-User-Idproves someone upstream wrote a string. It does not prove that principal may act on this resource under current policy. - Network trust is brittle. Private VPCs, service meshes, and “internal only” listeners get bypassed by tunnels, shared clusters, and future routing changes.
- Spoofing is an app bug. If the app accepts the header from any peer that can reach it, authentication was never end-to-end.
- Coarse identity skips object scope. Even a correctly authenticated Alice may be denied order
#4821after a transfer, expire, or role change — the header alone cannot know that. - Revocation stays invisible. Disabling SSO or removing a group membership does nothing if handlers never re-check the object.
The header is a convenient actor hint. Authorization is a decision about actor, action, and resource.
What to enforce
Keep the gateway — and keep authorization authoritative in the app:
- Treat injected identity as a claim to verify, not a grant. Map the header (or token) to a principal, then run the same object-level check you would for a public API.
- Strip and re-set at a single trust boundary. Only the authenticating edge may set identity headers; apps must reject or overwrite copies from clients and untrusted peers.
- Authorize every sensitive action. Load, mutate, list, export, and admin overrides all need
authorize(actor, action, resource)(or an equivalent policy decision) against current membership and ownership. - Fail closed when context is missing. If the principal cannot be bound or the policy store is unavailable, do not fall back to “header was present.”
- Log the decision, not only the header. Audit trails should record allow/deny with resource id — not merely that
X-User-Idarrived.
Tiny checklist
- If a peer on the same network sends
X-User-Id: adminwithout going through your authenticator, does the handler still serve/orders/{id}? - After Alice loses access to a tenant, can a warm request that still carries her injected header succeed?
- Do list and export endpoints filter by authorization, or only echo whatever the header says?
- Is there exactly one component allowed to set identity headers — and do apps strip client-supplied copies?
- For delete/approve paths, is there a per-object check, or only “any authenticated internal caller”?
Trusted headers help propagate who called. Authorization decides what they may do. A present X-User-Id is still not a permission check.
