Propagating JWT Claims to Subgraphs with Apollo Router
The router validates a token once; every subgraph then needs to know who the caller is without validating it again. Getting that handoff right is the difference between a graph where identity is trustworthy everywhere and one where each subgraph invents its own answer. This page covers what to forward, what never to forward, and how subgraphs should treat what arrives — part of Securing the Federated Gateway.
When to use this pattern
- Use it whenever subgraphs make authorization decisions of their own, which is almost always, because per-record decisions cannot be made at the router.
- Use it when you want validation to happen exactly once, so subgraphs are not each maintaining JWKS clients, clock-skew tolerances, and issuer lists.
- Skip claim forwarding entirely only when subgraphs make no identity-dependent decision at all — rare, and worth confirming rather than assuming.
Prerequisites
Two strategies, and why the second usually wins
Forward the raw token. The simplest option: propagate the authorization header and let each subgraph parse it. It works, requires no router configuration beyond one propagation rule, and every subgraph gets the full claim set.
Its costs accumulate quietly. Every subgraph now needs a JWKS client, key rotation handling, and clock-skew tolerance — five implementations of the same fiddly logic, which will not agree. Every subgraph can also read every claim, including ones it has no business seeing. And validation happens N+1 times per request, which is wasted work at exactly the point where latency is most visible.
Forward specific claims as headers. The router validates once, extracts the claims subgraphs actually need, and sends them as ordinary headers. Subgraphs read a header and trust it, because the only way to reach them is through the router.
That trust is the crux. It is entirely reasonable in a network where subgraphs are unreachable from outside, and completely unsafe in a flat network where anyone can set a header and call a subgraph directly. Decide which world you are in before choosing, because the second strategy is a network-topology assumption expressed as configuration.
Implementation walkthrough
The router validates the token, a Rhai script copies the claims you want into headers, and a propagation rule lets those headers through. All three parts are needed; omitting the last is the most common reason claims “disappear”.
# router.yaml
authentication:
router:
jwt:
jwks:
- url: https://idp.example.com/.well-known/jwks.json
poll_interval: 5m
issuer: https://idp.example.com/
header_name: authorization
header_value_prefix: Bearer
headers:
all:
request:
# Claims copied by the script below only reach subgraphs if listed here.
- propagate: { named: "x-user-id" }
- propagate: { named: "x-tenant-id" }
- propagate: { named: "x-scopes" }
# Deliberately NOT propagating "authorization": subgraphs read claims,
# not tokens, so a leaked subgraph log cannot leak a usable credential.
- remove: { named: "authorization" }
rhai:
scripts: "/dist/rhai"
main: "main.rhai"
// main.rhai — copy validated claims onto headers, once, at the supergraph stage
fn supergraph_service(service) {
service.map_request(|request| {
let claims = request.context["apollo_authentication::JWT::claims"];
if claims == () {
// No validated token. Do NOT invent headers — an absent claim must look
// absent to subgraphs, not like an anonymous user.
return request;
}
request.headers["x-user-id"] = claims["sub"];
request.headers["x-tenant-id"] = claims["tenant"];
// Scopes as a space-delimited string keeps parsing trivial on every side.
request.headers["x-scopes"] = claims["scope"];
request
});
}
On the subgraph side, read the headers in the context factory and treat their absence as anonymity rather than as an error — the router may legitimately forward an unauthenticated request for fields that permit it:
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
export interface Context {
userId: string | null;
tenantId: string | null;
scopes: Set<string>;
}
await startStandaloneServer(server, {
context: async ({ req }): Promise<Context> => ({
// Trustworthy because this service is unreachable except via the router.
userId: req.headers["x-user-id"] as string ?? null,
tenantId: req.headers["x-tenant-id"] as string ?? null,
scopes: new Set(((req.headers["x-scopes"] as string) ?? "").split(" ").filter(Boolean)),
}),
});
Verification steps
The decisive test is negative: confirm a client cannot set these headers itself.
# A client-supplied claim header must never survive the router.
curl -s localhost:4000/graphql \
-H 'content-type: application/json' \
-H 'x-user-id: attacker' \
-d '{"query":"{ me { id } }"}'
# expect: the subgraph sees no user id, or the id from a valid token — never "attacker"
If that request reaches a subgraph with x-user-id: attacker, the router is propagating a client header rather than one the script set, and every identity decision downstream is forgeable. The fix is to remove the header from the incoming request before the script writes it, or to use a header name clients cannot plausibly send.
Then confirm the positive path end to end: send a valid token, and assert in a subgraph integration test that the context contains the expected user, tenant and scopes. Finally, confirm an expired token is rejected at the router rather than being forwarded with claims intact — expiry checking belongs in exactly one place, and a subgraph that receives claims has no way to re-check it.
Because the pieces live in three different files, it is worth having a single picture of the whole path a claim travels — from the token arriving at the router to a value a resolver can branch on. Most misconfigurations are a missing step rather than a wrong one, and knowing the sequence makes the gap obvious.
Common mistakes & gotchas
Copying claims to headers but not propagating them. The script runs, the headers exist, and nothing arrives, because propagation is an allow-list and the new names are not on it. Add them explicitly.
Trusting a header a client could have set. If x-user-id arrives from the client and is not overwritten, identity is forgeable. Strip inbound copies of every claim header before writing your own.
Forwarding the raw token as well as the claims. It reintroduces every risk the extraction removed and gives subgraphs a second, possibly conflicting, source of truth about who the caller is.
Frequently Asked Questions
What if a subgraph genuinely needs a claim the router does not forward?
Add it to the script and the propagation list — that is the intended path, and it makes the dependency explicit in one reviewable place. What to avoid is forwarding the whole token because one subgraph needs one extra claim; that trades a five-line change for a permanent widening of what every service can see.
How do subgraphs verify the headers are genuine?
They do not, and that is the design. Verification happened once at the router; subgraphs trust the network boundary instead. This is only sound when subgraphs are unreachable except through the router, which makes network policy part of your authorization model rather than an unrelated concern.
Does this work for subscriptions?
Yes, with one important difference in timing. Headers are propagated when the subscription is established, so a subgraph sees the claims that were valid at that moment and does not see them change. A long-lived subscription therefore outlives token expiry unless you close connections on a schedule — see subscriptions in federated GraphQL.
Should scopes be forwarded if the router already enforces them?
Usually yes. The router’s @requiresScopes checks decide what appears in the response; a subgraph often needs the same scopes to decide which rows to return, and that decision cannot move to the router. Forwarding them costs one header and avoids a class of bug where the two layers disagree.
What happens on an unauthenticated request?
The script should write nothing and the subgraph should see absent headers. Writing a placeholder like anonymous is tempting and dangerous, because it becomes a valid-looking identity that some resolver will eventually treat as a real user. Absence is unambiguous; a sentinel is not.
How should these headers be named?
Pick one prefix, document it, and never let a claim header share a name with anything a client might legitimately send. A prefix such as x-graph- makes both properties obvious at a glance: a reviewer seeing x-graph-user-id in a subgraph knows immediately that it came from the router and is trustworthy, while x-user-id could plausibly be anything. The naming convention is doing security work rather than cosmetic work, because the whole model rests on being able to tell router-set headers from client-set ones at the point of use.
Related
- Securing the Federated Gateway — parent guide
- Directive Patterns for Cross-Service Authorization — what the router does with claims
- Implementing @authenticated and @requiresScopes Directives — the schema-side enforcement
- Rate Limiting Federated GraphQL Operations — quotas keyed on a validated claim
- Apollo Router Configuration and Deployment — header propagation in context