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.

Validate once, or validate everywhere Forwarding the raw token makes every subgraph validate it and see every claim. Extracting claims at the router validates once and sends each subgraph only the claims it needs. The same request, two handoffs raw token forwarded router validates accounts — validates again orders — validates again every subgraph owns JWKS, rotation and skew handling — and sees every claim claims extracted once router validates accounts — reads x-user-id orders — reads x-user-id one validation, and each subgraph sees only what it needs The lower model is only safe if subgraphs cannot be reached without going through the router.

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.

What crosses the boundary, and what stays Subject, tenant and scopes are forwarded because subgraphs need them. The raw token, refresh tokens and unrelated claims stay at the router because forwarding them adds risk without adding capability. Forward the minimum that lets a subgraph decide item forward? why subject (sub) yes per-record decisions need to know who tenant yes data isolation is enforced in the datasource raw access token no a subgraph log becomes a credential leak unrelated claims no nothing needs them; they only add exposure

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.

The five steps a claim passes through Validate the token, extract claims into context, write them as headers, propagate those headers, and read them in the subgraph context factory. Skipping any step produces the same symptom of missing identity. Five steps, three files, one symptom when any is missing 1. validate router.yaml 2. extract main.rhai 3. write header main.rhai 4. propagate router.yaml 5. read subgraph Step 4 is the one that gets skipped Header propagation is an allow-list. A header the script wrote but the list does not name is dropped silently at the subgraph hop, and the subgraph simply sees an anonymous request. When identity is missing, walk the five boxes in order rather than debugging the token — the token is almost always fine, and the gap is almost always between steps three and five.

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.