Propagating User Context Between Services

An internal call needs to answer two questions at once: which service is calling, and on whose behalf. Systems that answer only the first end up with services that trust each other completely; systems that answer only the second trust a header any workload on the network can set. This page is part of the policy enforcement points in microservices guide.

The Header You Must Not Trust

The pattern that appears in almost every codebase at some point is an internal call carrying X-User-Id. It is convenient, it is readable in logs, and it is an assertion with nothing behind it — any pod, job, or misrouted request that can reach the service can claim to be acting for any user.

An unauthenticated user header versus a verifiable pair A plain user identifier header can be set by anything on the network, while a call carrying a verifiable service identity together with a signed user context lets the receiver authorise both facts. Plain header X-User-Id: 4711 anything on the network can set it no boundary inside the network Verifiable pair service identity (mTLS or token) plus signed user context both facts can be checked
The receiving service authorises the pair: this caller, acting for this user, may do this — which is a different and stronger question than either half alone.

Two Workable Designs

There are two arrangements that hold up, and the choice depends on how much you trust your network boundary and how many hops a request makes.

Forward the original token. The user’s access token travels with the request, and each service verifies it exactly as the gateway did. This keeps the identity assertion signed by the identity provider all the way down, which is easy to reason about. The costs are that every service must be able to verify provider tokens, the token’s audience must cover them, and a long chain gives a leaked token broad reach.

Mint an internal token at the edge. The gateway verifies the user’s token once and issues a short-lived internal token naming the user, the calling service, and a narrow audience for the next hop. Downstream services verify your signature rather than the provider’s, lifetimes are measured in seconds, and each token is scoped to the call it was minted for. The cost is a minting service and key management you own.

// Minting a narrow internal token for one downstream call.
export async function internalTokenFor(user: Subject, caller: string, audience: string) {
  return new SignJWT({ sub: user.id, tenant: user.tenantId, act: { sub: caller } })
    .setProtectedHeader({ alg: "EdDSA", kid: currentKid })
    .setIssuer("https://internal.example")
    .setAudience(audience)               // one downstream service, not "internal"
    .setExpirationTime("60s")            // seconds, not minutes
    .sign(internalKey);
}

The act claim records the acting service, which is what lets a receiving service distinguish “the API gateway, on behalf of user 4711” from “the reporting job, on behalf of user 4711” — a distinction that matters when the two should be allowed to do different things.

Scope the Audience to One Hop

The single most valuable property of a minted token is a narrow audience. A token addressed to internal is accepted by everything; a token addressed to billing-api is useless anywhere else, so a compromised or overly curious service cannot replay it sideways.

Broad versus per-hop audience scoping A token addressed to a generic internal audience can be replayed against any service, while a token addressed to a single downstream service is worthless elsewhere. aud: internal accepted by every service one leak reaches everything aud: billing-api accepted by one service useless anywhere else
Per-hop audiences turn a stolen internal token from a general-purpose credential into a single-use key for one door.

Keep lifetimes to seconds. Internal calls complete quickly, and a sixty-second token is long enough for retries while being effectively worthless if captured from a log. Do not reuse a minted token across calls to different services just because it saves a signature.

Asynchronous Work Needs the Same Discipline

Queues and scheduled jobs are where user context is most often lost, because there is no request to inherit from. A job that processes “user 4711’s export” and reads that identifier from its payload is trusting the payload, which is the same problem as trusting a header.

Carry a signed context in the message rather than a bare identifier, with a lifetime that accounts for queue delay, or record the authorising session and re-derive permissions when the job runs. The second is usually better for long-running work: permissions can change between enqueue and execution, and a job that acts on stale authority is exactly the audit finding you do not want.

Either way, jobs must set the tenant explicitly before touching data, as described in multi-tenant authorization and data isolation. A worker with no tenant context is a query with no boundary.

Logging and Tracing the Pair

Every internal call should log both identities and a correlation identifier, so a request can be followed from the browser through each hop and each authorization decision. Without the acting-service field, an audit trail records that user 4711 deleted something and cannot say which component did it on their behalf.

Propagate the correlation identifier explicitly rather than relying on a tracing library to do it for you on every path — background jobs and message consumers are precisely where automatic propagation tends to stop, and they are where reconstructing a chain matters most.

Keep the tokens themselves out of logs. Log the subject, the acting service, the audience and the expiry; never the token. Internal tokens are short-lived, which reduces but does not remove the value of one captured from a log aggregator that many people can read.

Migrating From Trusted Headers

Incremental migration away from header-based propagation Add the verified context alongside the header, then prefer it with a logged fallback, then remove the header path service by service starting with the most sensitive. 1 · add alongside nothing changes yet 2 · prefer, log fallback the burn-down number 3 · remove per service sensitive ones first Removing the header everywhere at once turns one forgotten caller into an outage.
The fallback counter in step two is the only reliable way to find the callers nobody remembers — a scheduled job, an admin tool, a direct integration.

Most systems arrive here with header-based propagation already in place across dozens of services, and the migration has to be incremental because a flag day is not realistic.

Start by adding the verifiable context alongside the existing header rather than replacing it. Services continue reading the header while you deploy minting at the edge and verification in each service, and each service logs whether a verified context was present. That counter tells you which callers have not been updated, which is information you cannot get any other way once the mesh is more than a handful of services.

Then flip services to prefer the verified context and fall back to the header, still logging fallbacks. The fallback rate becomes the burn-down chart, and it will surface callers nobody remembered: a scheduled job, an internal admin tool, a legacy integration calling the service directly rather than through the gateway.

Finally, remove the header path service by service, starting with the ones handling the most sensitive data. Do not remove it globally in one change — a single missed caller turns into an outage for a feature nobody associated with authentication, and the fix will be to re-enable the header everywhere.

Record a completion date per service so the migration has a visible finish line rather than trailing off. Keep the fallback behind configuration rather than a code path you have to redeploy to change. The ability to re-enable header trust for one service for an hour, with an alert while it is on, is what keeps the migration moving instead of stalling on the one caller nobody can identify.

Frequently Asked Questions

Is mutual TLS enough on its own?

It authenticates the service, not the user. Mutual TLS tells the receiver which workload is calling, which is half the answer and a good half — but without a user context it cannot distinguish a legitimate request on behalf of one user from a request on behalf of another. Use it to establish service identity and carry the user context inside the request.

Should I forward the original access token instead of minting one?

It works and it is simpler, provided every service can verify provider tokens and the audience covers them. The trade is reach: a forwarded token is accepted wherever it is valid, so a long call chain widens the blast radius of a leak. Minting narrow, short-lived internal tokens costs a service to run and gives you per-hop scoping in return.

How long should an internal token live?

Seconds — long enough for the call and a couple of retries. Internal hops complete in milliseconds, so a sixty-second lifetime is generous, and it makes a token captured from a log or a trace effectively worthless by the time anyone reads it.

What about calls that are not on behalf of a user?

Give them their own identity rather than borrowing a user’s. A scheduled reconciliation job is a service acting for itself, and modelling it that way keeps the audit trail honest and lets you grant it a narrow, specific set of permissions. Running background work as a real user’s identity is how audit trails end up blaming people for actions they never took.

Does this replace the service's own authorization checks?

No. Propagation carries the facts; the receiving service still decides what those facts permit. A verified context saying “the gateway, for user 4711 in tenant A” is input to an authorization decision, not the decision itself — the object-level check against the record being touched still belongs in the service that owns the data.