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.
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.
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
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.
Related
- Policy enforcement points in microservices — where the decisions this context feeds are made.
- Multi-tenant authorization and data isolation — why the tenant must travel with the user.
- Validating JWTs and rotating signing keys — verifying whichever token you choose to carry.