By AndyPublished
JWT in Microservices: Gateway Validation, Token Propagation and Service-to-Service Auth
aud so a token for one cannot be replayed at another; authenticate service-to-service calls with mTLS or client-credentials tokens, with SPIFFE as the standard for workload identity; and cache JWKS with a bounded lifetime so verification stays fast without going stale on key rotation. This guide covers each, with a diagram of the request path.JWT Microservices Architecture at a Glance
A JWT-based microservices setup has three moving parts: an authorisation server (often your OIDC provider) that issues tokens and publishes a jwks_uri; an API gateway that is the single ingress; and the services behind it. The client authenticates once, receives a token, and sends it on every call. The gateway and services verify the signature against the authorisation server's public keys, no call back to the issuer per request.
issues tokens, publishes JWKS
+---------------------------+
| Authorisation server | <---- /.well-known/openid-configuration
+---------------------------+ jwks_uri (public keys)
^ \ (JWKS fetched + cached by verifiers)
login / | \_______________________
token | | |
+--------+ JWT (aud=gateway) +--------+ +--------+
client | | ------------------> |Gateway | ... | ... |
+--------+ +--------+ +--------+
| validates, then propagates
| or exchanges the token
aud=orders v v aud=billing
+---------+ +---------+
| Orders |-->| Billing | each service validates again
+---------+ +---------+ (zero trust)JWT Validation: API Gateway vs Every Service
The gateway is the natural place for a first, coarse validation: reject anything unsigned, expired or malformed before it reaches the mesh, and strip client-supplied headers that could spoof identity. The tempting next step, "the gateway checked it, so services can trust the network", is the mistake zero trust exists to prevent. If any one service is compromised, or a bug lets an internal caller reach a service directly, unchecked services will honour whatever they receive.
The zero-trust position is that every service validates every token it receives: signature against the JWKS, exp, iss, and its own aud. Verification is a signature check and a few comparisons, cheap enough to do per request. The gateway still earns its place by centralising rate limiting, coarse authz, and the swap to internal tokens described below.
| Pattern | Who validates | Trade-off |
|---|---|---|
| Gateway only | The API gateway; services trust it | Simple, but a compromised service can call others freely |
| Gateway + every service | Gateway and each service independently | Zero trust; each service needs the JWKS and the checks |
| Phantom token | Gateway swaps opaque token for a JWT inward | Opaque outside, JWT inside; revocation stays easy |
| Token exchange (RFC 8693) | Each hop gets a narrowed token | Least privilege per call; needs an authorisation server |
JWT Between Microservices: Propagation vs Exchange
When Orders calls Billing on behalf of a user, two models exist for the token on that internal hop.
Token propagation (pass-through)
Orders forwards the user's token as received. Simple, and the user's identity flows end to end, but the token was minted for the whole request: every downstream service sees the user's full set of scopes, and a token stolen from one hop works at every hop. It also fails audience checks if each service expects its own aud, which pushes people towards a single shared audience, weakening the very control that stops replay.
Token exchange (RFC 8693)
Instead of forwarding, Orders exchanges the incoming token at the authorisation server for a new one scoped to exactly what Billing needs, addressed to Billing's audience. RFC 8693, OAuth 2.0 Token Exchange, standardises this: the caller presents a subject_token and asks for a token with a specific audience and reduced scope, and the may-act (act) claim records the delegation chain. The result is least privilege per call and audience isolation, at the cost of a round trip to the authorisation server on the exchange (cacheable for the token's lifetime) and more moving parts. Use propagation for simple read paths, exchange where a downstream service should not receive the user's full authority.
A Distinct Audience Per Service
The aud claim names who a token is for, and each service must reject tokens whose aud is not its own identifier. This is what stops a token intended for the profile service being replayed against the payments service. It only works if tokens are minted per audience, which is the strongest argument for token exchange over pass-through in sensitive paths. The mechanics of matching, and the array-vs-string subtlety, are in the aud claim guide. RFC 9068 (JWT access tokens) additionally recommends a typ of at+jwt so an ID token can never be substituted for an access token between services.
Service-to-Service Authentication
A user token answers "who is the user"; it does not answer "is this really the Orders service calling". For calls with no user, a scheduled job, an internal event handler, services authenticate as themselves. Three common mechanisms:
- ·Client credentials (OAuth 2.0): a service authenticates to the authorisation server with its own credentials and receives a JWT with a
subidentifying the service, not a user. Standard, and fits the same verification path as user tokens. - ·mTLS: each service presents a client certificate on the TLS connection, so the transport itself proves identity. Often terminated at a service mesh sidecar so application code stays unchanged. RFC 8705 also allows binding a JWT access token to the client certificate.
- ·SPIFFE / SPIRE: SPIFFE gives each workload a verifiable identity (a SPIFFE ID such as
spiffe://example.org/orders) delivered as an X.509 certificate (X509-SVID) or a JWT (JWT-SVID). SPIRE is the reference implementation that attests and rotates these automatically. It is the emerging standard for machine identity behind mTLS.
User identity and service identity are different questions, and a robust system answers both: the JWT carries the user, mTLS or a client-credentials token carries the workload. The difference from a plain API key is covered in JWT vs API key.
The Phantom Token Pattern: Opaque Outside, JWT Inside
A JWT in the browser leaks its claims to anyone who decodes it and cannot be revoked without extra machinery. The phantom token pattern keeps the best of both worlds: the client holds an opaquereference token; the gateway calls the authorisation server's introspection endpoint (RFC 7662) or otherwise looks it up, and injects a JWT for internal calls. Externally you get easy revocation (invalidate the opaque token and it is dead at once) and no leaked claims; internally services get a self-contained JWT they can verify locally without calling the issuer per request. The related split token variant sends only the signature to the client and reassembles the JWT at the gateway. Both trade a lookup at the edge for revocability, which is often the right exchange; contrast with JWT logout and revocation where the JWT reaches the client directly.
JWKS Caching and Key Rotation
Every verifying service fetches the issuer's public keys from its jwks_uri(see what is JWKS) and caches them, otherwise it would call the issuer on every request and couple every service's availability to it. The caching rules that keep this both fast and correct:
- ·Cache by
kid: pick the key whosekidmatches the token header, so rotation (a new key alongside the old) works without a flush. - ·Bound the cache age: keep keys for minutes, not forever, so a retired key eventually leaves the cache. Libraries like
josedefault to a 10-minute cache and a 30-second cooldown before re-fetching on an unknown key. - ·Re-fetch on an unknown
kid, with a cooldown: when a token arrives signed by a key you have not seen, fetch once, but rate-limit that fetch so a flood of badkids cannot hammer the issuer. - ·Overlap keys during rotation: publish the new key before signing with it and retire the old one only after all outstanding tokens signed by it have expired.
Stale JWKS is the most common cause of "signature suddenly fails after a rotation": the verifier cached the old set and did not re-fetch on the new kid. You can confirm which kid a token names by decoding its header in the jwtdecode.app decoder, which runs locally in the browser, then check that kid is present at the jwks_uri.
Clock Skew Across Services
Time claims are compared against each verifier's own clock, and hosts drift. A service running a minute fast rejects freshly issued tokens as expired; one running slow rejects tokens as not yet valid via nbf. Run NTP everywhere and allow a small leeway, typically 30 to 60 seconds, in each verifier's configuration. In a mesh of many services this is not optional: without it, a single badly synced node produces intermittent 401s that are painful to trace.
JWT Authentication in Microservices with Spring Boot
In Spring, each service is an OAuth2 resource server. Point it at the issuer and it discovers the jwks_uri, fetches and caches the keys, and validates the signature, exp and iss automatically. Set each service's own audience so tokens for another service are rejected.
# application.yml on the Orders service
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/
audiences:
- orders-api # reject tokens whose aud is not this serviceThe audiences property has validated the aud claim since Spring Boot 2.7; for anything more (a token-version check, a denylist) add an OAuth2TokenValidator<Jwt>. Verify exact property names against your Spring Boot version. The full setup is in JWT authentication in Spring Boot.
Summary
Across microservices, validate JWTs at the gateway and again in every service (zero trust), and give each service its own aud. Forward the user's token only for simple paths; use RFC 8693 token exchange to hand downstream services a narrower token for their audience. Prove workload identity separately with mTLS or client-credentials tokens, standardised by SPIFFE. Keep the user's raw JWT off the client with the phantom token pattern when you need easy revocation, and cache JWKS with a bounded age and a per-kid re-fetch so rotation is seamless. Finally, run NTP and allow a little clock leeway, because a single skewed node produces 401s that look like a token bug and are not.