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 sub plus act chain 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:

  1. 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.
  2. 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.
  3. The token records who and through whom. The user stays in sub on every hop. The act claim records the calling service, nested to show the earlier chain.
  4. 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, exp and the required claims, and confirms that act.sub matches the mTLS peer. It then applies its own resource-level authorization using sub.

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-exchange
  • subject_token: the token representing the user, with subject_token_type (for example urn:ietf:params:oauth:token-type:access_token or ...:jwt)
  • actor_token and actor_token_type: the calling service's own credential, proving who is acting
  • audience: 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:

  1. Parse the header without trusting it. Reject the token if alg is not on your allowlist.
  2. Look up the kid in a cached JWKS from the exchange service. If the kid is unknown, refetch once, rate-limited to something like one fetch per few seconds, then reject. Without the limit, a flood of tokens with random kid values becomes a flood of requests to your issuer.
  3. Verify the signature with that key.
  4. Check claims. iss must match exactly. aud must contain this service's own identifier. exp and nbf must 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: aud is service-a, exp is valid, and act.sub matches 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, kid lookup with rate-limited refetch, exact iss and aud matching, 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 outermost act and jti, 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 sub arrives 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.