Why forwarding the user's access token to every service is unsafe
Microservice user identity propagation is how a request carries the end user's identity from the edge, where they authenticated, to every internal service that makes an authorization decision on their behalf. Without it, a service three hops deep can't tell whether it is reading Alice's invoices or Bob's. The usual shortcut is to forward the bearer token the user presented at the gateway. That works, but it widens the blast radius of every service.
The edge access token was issued for a public API, so its audience covers anything behind the gateway. Every service that receives it holds a credential the others will accept. A token leaked from one low-value service through logs, a heap dump or a compromised dependency can be replayed against all of them. Its lifetime is also sized for a user session, often minutes to hours, while an internal call finishes in milliseconds.
| Property | Forwarded edge token | Per-hop internal token |
|---|---|---|
| Audience | Entire API surface | One target service |
| Replay scope if leaked | Every service | Only the named recipient |
| Lifetime | User session scale | Seconds |
| Who can mint it | External identity provider | Internal issuer you control |
Zero trust services answer two separate questions on every request. Which workload is calling? Workload identity answers this, for example mTLS certificates. Which user is it acting for? A signed user context answers this, scoped to that hop. Mixing the two lets any caller who holds a token act as that user.
This pattern is for backend and platform teams running many services that each make per-user authorization decisions.
Who should use this pattern
Signed, exchanged internal JWTs fit teams whose systems have all of the following:
- Several services that make per-user decisions. If an orders service, a billing service and a documents service each need to know "may this user see this record?", each one needs a trustworthy user identity, not just the identity of the calling workload.
- More than one trust boundary. Services owned by different teams, running different dependencies, or holding data of different sensitivity. A compromise in one should not hand an attacker a credential the others accept.
- An existing workload identity layer, or a plan for one. The pattern relies on mTLS or another workload credential to answer "which service is calling?". Exchanged tokens answer the user question on top of that. They don't replace it.
- Audit or compliance needs. If you must show which user triggered an action and which services acted for them, the
subplusactchain gives you that record on every hop.
Platform teams are usually the ones who build and run the token service and the shared verification libraries. Product service teams then consume it: they register an audience name, verify inbound tokens with the shared library, and request exchanges for downstream calls.
It is a poor fit for a single service or a small system run by one team behind one trust boundary, and for internal services that only do service-level authorization and never look at the user. The trade-offs section below covers those cases in more detail.
How it works, in brief
The pattern has four moving parts:
- The edge token stops at the gateway. The client authenticates with the identity provider and presents its access token to the gateway. The gateway validates it and runs coarse checks, but never forwards it.
- Every hop exchanges, never forwards. Before calling a downstream service, the caller (the gateway or any internal service) sends the token it received, plus proof of its own identity, to an internal security token service. The STS checks policy and returns a new short-lived JWT whose audience is only the target service.
- The token records who and through whom. The user stays in
subon every hop. Theactclaim records the calling service, nested to show the earlier chain. - Each receiver verifies locally and decides for itself. The receiving service checks the signature against the STS's published public keys, pins the algorithm, checks
iss,aud,expand the required claims, and confirms thatact.submatches the mTLS peer. It then applies its own resource-level authorization usingsub.
The sections that follow cover each step in detail: the exchange protocol, the token's contents, verification and key management, and a full request flow across two hops.
How token exchange works (RFC 8693)
A service that needs to call downstream doesn't forward what it received. It presents that token to a security token service (STS) and asks for a new one. RFC 8693 defines this as OAuth 2.0 Token Exchange, a grant posted to the token endpoint with these parameters:
grant_type=urn:ietf:params:oauth:grant-type:token-exchangesubject_token: the token representing the user, withsubject_token_type(for exampleurn:ietf:params:oauth:token-type:access_tokenor...:jwt)actor_tokenandactor_token_type: the calling service's own credential, proving who is actingaudience: the logical name of the service the new token is for
The response carries access_token, issued_token_type, token_type and expires_in. The STS is a policy point, not a stamp. It decides whether this actor may act for this subject toward this audience, and it can narrow scopes or shorten lifetime in the token it issues.
The issued JWT keeps the user in sub and records the caller in an act claim. When a second hop exchanges again, the previous act is nested inside the new one, so the outermost act is the current actor and inner ones are history. RFC 8693 treats the nested entries as informational, so authorization should rely on sub and the outermost actor. With deep call chains, tokens grow with each hop, which is one reason to keep chains shallow.
Per-service audiences limit replay, because a token minted for orders is rejected by billing. The cost is one exchange per hop. A single shared internal audience avoids that round trip, but any internal service can then replay any token it receives.
curl -s \
-u 'orders-svc:PLACEHOLDER_CLIENT_SECRET' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=${USER_TOKEN}" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode "actor_token=${ORDERS_SVC_TOKEN}" \
--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'audience=billing'
An RFC 8693 token exchange request from the orders service for a billing-scoped token. The example shows the response shape and the issued JWT, whose nested act claims record the delegation chain.
Designing the internal JWT
Each exchanged token should carry only what the receiving service needs to make a decision and to audit it. A decoded payload for the billing hop looks like this: {"iss": "internal-sts", "sub": "user-8f3a2c", "aud": "billing", "iat": 1767225600, "exp": 1767225720, "jti": "c41e9b07", "act": {"sub": "svc:orders"}, "scope": "invoice:read"}.
sub identifies the user with an opaque ID, and act records which service is acting on that user's behalf. jti lets a receiver reject replays inside the validity window. Leave out email addresses, names and other PII, because tokens end up in logs, traces and error reports. Also leave out large permission lists, which inflate every request header and can exceed proxy header size limits.
The token above lives for two minutes. A short lifetime limits the damage from a leaked token, but every hop then re-exchanges more often, which adds load and latency at the token service. Caching tokens per audience until shortly before expiry reduces that load. Clock skew also matters more as lifetimes shrink. If hosts disagree by 30 seconds, that is a quarter of a two-minute window, so allow a small leeway on exp and iat and keep NTP healthy.
The harder choice is where authorization data lives:
| Approach | Benefit | Cost |
|---|---|---|
| Roles embedded in token | No per-request lookup | Revoked roles persist until expiry |
| Minimal token, per-service check | Always-current permissions | Extra call per request, plus a dependency on the permission service |
Embedding a few coarse scopes in the token while doing fine-grained checks locally is a common compromise between the two.
import time
import jwt # PyJWT
PUBLIC_KEY = open("/etc/billing/sts-public.pem").read() # STS signing key (placeholder path)
SEEN_JTI: dict[str, int] = {} # jti -> exp; use a shared store if billing runs multiple replicas
def verify_internal_token(token: str, required_scope: str) -> dict:
claims = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"], # pin the algorithm; never trust the header's choice
audience="billing", # reject tokens minted for other services
issuer="internal-sts",
leeway=10, # seconds of clock-skew tolerance on exp/iat/nbf
options={"require": ["iss", "sub", "aud", "exp", "iat", "jti"]},
)
# Replay protection: a jti may be used once within its validity window.
now = int(time.time())
for jti, exp in list(SEEN_JTI.items()):
if exp + 10 < now: # drop entries that can no longer validate anyway
del SEEN_JTI[jti]
if claims["jti"] in SEEN_JTI:
raise PermissionError("replayed token")
SEEN_JTI[claims["jti"]] = claims["exp"]
# Coarse authorization from the embedded scope; fine-grained checks happen later.
if required_scope not in claims.get("scope", "").split():
raise PermissionError(f"missing scope {required_scope}")
# Record both the user and the calling service for the audit log.
actor = claims.get("act", {}).get("sub", "unknown")
print(f"audit user={claims['sub']} actor={actor} jti={claims['jti']}")
return claims
A receiving service uses PyJWT to verify an exchanged token's signature, audience, issuer and required claims with a small clock-skew leeway, then rejects replayed jti values and checks the coarse scope before logging the user and acting service.
Verifying signatures and managing keys correctly
A verifier's job is to reject anything it did not explicitly expect. The most common failure is letting the token describe how it should be checked. The alg header is attacker-controlled. A library that honors it can be tricked into accepting none, or into treating a public RSA key as an HMAC secret. Pin the algorithm in configuration, for example ES256 only, and fail any token whose header disagrees.
A library-agnostic verification sketch:
- Parse the header without trusting it. Reject the token if
algis not on your allowlist. - Look up the
kidin a cached JWKS from the exchange service. If thekidis unknown, refetch once, rate-limited to something like one fetch per few seconds, then reject. Without the limit, a flood of tokens with randomkidvalues becomes a flood of requests to your issuer. - Verify the signature with that key.
- Check claims.
issmust match exactly.audmust contain this service's own identifier.expandnbfmust hold, with a small skew allowance (tens of seconds, not minutes).
Use asymmetric signing so that downstream services hold only public keys. A compromised verifier then cannot mint tokens. Rotate keys with overlap: publish the new key, wait for caches to expire, start signing with it, and retire the old key only after every token it signed has expired.
Use a well-maintained JWT library rather than parsing tokens yourself. Test it with negative cases: a wrong audience, an expired token, an unexpected algorithm, and an unknown kid. Every one of these must be rejected.
import { createRemoteJWKSet, createLocalJWKSet, jwtVerify, SignJWT, generateKeyPair, exportJWK } from 'jose';
import assert from 'node:assert/strict';
const ISSUER = '';
// Production key set: cached, refetched on unknown kid, at most one refetch per 5s.
export const remoteJwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
cacheMaxAge: 10 * 60 * 1000,
cooldownDuration: 5_000,
});
// Policy comes from config, never from the token header.
export async function verify(token: string, keys: Parameters<typeof jwtVerify>[1]) {
const { payload } = await jwtVerify(token, keys, {
algorithms: ['ES256'], // allowlist; 'none' and HS* are rejected
issuer: ISSUER, // exact match
audience: 'orders-service', // this service's own identifier
clockTolerance: 30, // seconds of skew for exp/nbf
requiredClaims: ['exp', 'sub'],
});
return payload;
}
// Negative tests against a local key set built from a throwaway test key.
const { publicKey, privateKey } = await generateKeyPair('ES256');
const keys = createLocalJWKSet({ keys: [{ ...(await exportJWK(publicKey)), kid: 'test-1', alg: 'ES256' }] });
const mint = (claims: object, kid = 'test-1', exp: string | number = '5m') =>
new SignJWT({ sub: 'user-123', ...claims }).setProtectedHeader({ alg: 'ES256', kid })
.setIssuer(ISSUER).setIssuedAt().setExpirationTime(exp).sign(privateKey);
await verify(await mint({ aud: 'orders-service' }), keys); // happy path must pass
await assert.rejects(verify(await mint({ aud: 'billing-service' }), keys)); // wrong audience
await assert.rejects(verify(await mint({ aud: 'orders-service' }, 'test-1', Math.floor(Date.now() / 1000) - 120), keys)); // expired beyond skew
await assert.rejects(verify(await mint({ aud: 'orders-service' }, 'unknown-kid'), keys)); // unknown kid
const hs = await new SignJWT({ sub: 'user-123', aud: 'orders-service' })
.setProtectedHeader({ alg: 'HS256', kid: 'test-1' }).setIssuer(ISSUER).setExpirationTime('5m')
.sign(new TextEncoder().encode('placeholder-not-a-real-secret-0123456789'));
await assert.rejects(verify(hs, keys)); // unexpected algorithm
Verifying exchanged JWTs with the jose library using a pinned ES256 allowlist, a cached and rate-limited remote JWKS, exact iss/aud checks with 30 seconds of clock skew, and negative tests for wrong audience, expiry, unknown kid and an unexpected algorithm.
A worked request flow from edge login to two downstream hops
Hop 0: edge login. The identity provider issues an access token to the client: sub=user-123, aud=api-gateway, a short exp, and broad scopes such as orders:read. This token never travels past the gateway.
Hop 1: gateway to Service A. The gateway runs coarse checks: a valid signature, an unexpired token, the orders:read scope present, and rate limits. It then calls the token service with the edge token as subject_token and gets back a new JWT with sub=user-123, aud=service-a, and act={sub: gateway}.
Service A verifies three things:
- The mTLS peer identity is the gateway.
- The signature matches a key in the trusted set.
- The claims are correct:
audisservice-a,expis valid, andact.submatches the mTLS peer.
That last comparison matters. It means a token stolen from the gateway cannot be replayed from another workload.
Hop 2: Service A to Service B. Service A exchanges its inbound token and gets sub=user-123, aud=service-b, and a nested act={sub: service-a, act: {sub: gateway}}. Service B makes the same three checks, this time expecting service-a as both the peer and act.sub.
Responsibility split. The sub claim stays constant across both hops. Only aud and act change, which narrows the token's use and records who called whom. The gateway cannot answer "does user-123 own order 42?" because it has no order data. Service A answers that question with the propagated sub. Service B answers its own resource questions, such as "may this user see this payment record?", the same way.
+--------+ login +-------------------+
| Client |----------->| Identity Provider |
+--------+ +-------------------+
| edge token: sub=user-123 aud=api-gateway scope=orders:read
v
+--------------------------------------+ subject_token +---------------+
| API Gateway: coarse checks |---------------->| Token Service |
| sig, exp, scope orders:read, limits |<----------------| (exchange) |
+--------------------------------------+ aud=service-a +---------------+
| mTLS peer = gateway ^ |
| sub=user-123 aud=service-a act={sub:gateway} | |
v | |
+--------------------------------------+ subject_token | |
| Service A |--------------------+ |
| verify: peer=gateway, sig, aud, exp, |<-----------------------+
| act.sub == peer | aud=service-b
| authz: does user-123 own order 42? |
+--------------------------------------+
| mTLS peer = service-a
| sub=user-123 aud=service-b
| act={sub:service-a, act={sub:gateway}}
v
+--------------------------------------+
| Service B |
| verify: peer=service-a, sig, aud, |
| exp, act.sub == peer |
| authz: may user-123 see payment rec? |
+--------------------------------------+
One request from edge login through two token exchanges: sub stays user-123 while aud narrows to each callee and act records the gateway and Service A, which each receiver checks against its mTLS peer before applying its own resource-level authorization.
Async messaging, background jobs and fan-out
That model holds while every hop happens inside one request. Queues and background jobs break it. An internal token minted with a lifetime of a few minutes may expire before a consumer picks the message up. A retry hours later fails verification, or someone "fixes" it by raising the lifetime. The token also drifts from reality: by the time the job runs, the user may have logged out, lost a role, or been deleted, and the token still asserts the old state.
| Situation | Identity to use | Why |
|---|---|---|
| Synchronous fan-out to several services within one request | Propagated user-scoped token | Every call completes inside the token's lifetime, and each service makes its own decision about the user |
| Scheduled or system work (reindexing, cleanup, billing runs) | The service's own identity | No user is present, so pretending one is creates false audit trails |
| User-triggered async work (export, report generation) | Service identity plus an audit record of the originating user | The consumer acts as itself but keeps attribution, and it can re-check the user's current permissions when it runs |
For the third case, write the originating sub, tenant, request ID and enqueue time into the message as plain data, not as a credential. The consumer authenticates as itself and treats those fields as facts to log and to authorize against.
Never put long-lived tokens in messages. Queues persist to disk, get replicated, land in dead-letter queues, and show up in debugging dumps. Each copy is a bearer credential that anyone with read access can replay until it expires.
Trade-offs, alternatives and when to avoid this pattern
Token exchange puts a network call on the hot path. That adds latency to every first hop, and it makes the token service a dependency of internal traffic. If the token service goes down, internal calls fail. Caching exchanged tokens per user and audience reduces that load, but it also means a leaked token can be replayed for its full remaining lifetime. Stateless JWTs also cannot be revoked immediately. Adding a denylist or introspection fixes revocation, but it brings back the shared state the design was meant to remove.
| Approach | Main weakness | Reasonable when |
|---|---|---|
| Forward the edge token | Broad audience: any service holding it can replay it anywhere | One trust boundary, few services |
| mTLS identity plus unsigned user headers | Any compromised service can claim to be any user | Coarse, service-level authorization only |
| Introspection on every call | Adds latency and auth-server load on each hop | Immediate revocation is a hard requirement |
| Central policy decision point | Adds another hot-path dependency and still needs a trustworthy identity as input | Complex shared policy; it complements exchange rather than replacing it |
Skip the full pattern if you have a small system, a single team and trust boundary, or downstream services that make no per-user decisions.
Illustrative estimate. Assume these inputs: 4 engineer-weeks to build the token service, 2 weeks per shared verification library in each of two languages, and 2 weeks for key-rotation automation. That totals about 10 engineer-weeks upfront, plus roughly 1 week per quarter of upkeep. Now assume 20 services that would otherwise each spend 1 week on their own authorization code, with ongoing drift between them. Under those assumptions, the pattern breaks even within the first year. That is before you count simpler audits and the smaller blast radius when a service is compromised. With five services, it probably does not break even.
How the pattern saves engineering time
The savings come from doing the hard identity work once, centrally, instead of once per service. Specifically:
- One verification path instead of many. Each service imports the shared verification library and passes its own audience name. The library carries the decisions that are easy to get wrong: algorithm pinning,
kidlookup with rate-limited refetch, exactissandaudmatching, clock-skew leeway, and required claims. Service teams don't each design, review and debug their own token handling. - Reusable negative tests. The rejection cases shown earlier (wrong audience, expired token, unknown
kid, unexpected algorithm) live with the shared library. A fix or a new test case reaches every service when they update the dependency, instead of being rediscovered team by team. - Key rotation in one place. Downstream services only fetch public keys from the STS's JWKS. Rotating the signing key is a change to the token service and its publishing schedule, not a coordinated secret rollout across every deployment.
- Faster onboarding of new services. Adding a service means choosing an audience name, adding STS policy for which actors may call it, and wiring in the library. There is no new credential to distribute to it, and no existing service has to change to start trusting it.
- Policy changes without redeploys. Which service may act for a user toward which audience, and with what scopes and lifetime, is decided at the STS. Tightening that policy is a change in one component rather than edits across many codebases.
- Shorter incident investigations. Every receiver logs
sub, the outermostactandjti, so "which user triggered this, and through which services?" is answered from audit logs rather than by correlating request IDs across teams. When a service is compromised, the per-service audience limits which other services its stolen tokens could have reached, which narrows what needs to be checked. - Less bespoke authorization plumbing. Because
subarrives verified on every hop, service teams spend their effort on resource-level questions, such as whether this user owns this order, rather than on re-establishing who the user is.
The illustrative estimate in the previous section shows how these savings scale with the number of services under its assumed inputs. The fewer services make per-user decisions, the smaller the saving, which is why small systems are better served by a simpler approach.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to microservice user identity propagation, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.