By AndyPublished

JWT in Microservices: Gateway Validation, Token Propagation and Service-to-Service Auth

JWTs suit microservices because verification needs no shared session store: any service holding the issuer's public key can check a token locally. The real design questions are who validates it, how it travels between services, and how services prove their own identity to each other. The short answers: validate at the gateway and in every service (zero trust, don't trust the network); prefer token exchange (RFC 8693) over blindly forwarding the user's token when a call needs narrower rights; give each service its own 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.

PatternWho validatesTrade-off
Gateway onlyThe API gateway; services trust itSimple, but a compromised service can call others freely
Gateway + every serviceGateway and each service independentlyZero trust; each service needs the JWKS and the checks
Phantom tokenGateway swaps opaque token for a JWT inwardOpaque outside, JWT inside; revocation stays easy
Token exchange (RFC 8693)Each hop gets a narrowed tokenLeast 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 sub identifying 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 whose kid matches 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 jose default 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 bad kids 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 service

The 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.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder