Skip to Content
architecturesecurityoauth2java

Securing Microservices with OAuth2 Token Exchange

AUTHENTICATED_ACCESS_GRANTED

The Context: "The 3 AM Trigger"

It was April 2024. Our e-commerce client, "CartNova," utilized a standard OAuth2 workflow. A user logged in on the React frontend, received a JWT, and passed it to the Orders API via an Authorization header. If the Orders API needed to fetch loyalty points, it simply forwarded the exact same User JWT to the Loyalty API. We thought this was a brilliant, stateless AuthZ architecture. Then the Red Team hit us. They compromised a slightly vulnerable third-party reporting service deep within the cluster. Because every internal service blindly passed the original User JWT downstream, the reporting service captured a highly privileged admin JWT. The Red Team used that stolen Admin JWT to call the Orders API directly, bypassing the UI, and initiated refunds. This is the "Confused Deputy" problem. Total baigan trust model. Passing raw User JWTs between backend services is a catastrophic security vulnerability. We needed explicit Token Exchange.

The Problem Statement

Our intra-cluster security constraints:

  • Audience Restriction: A token minted for the Orders API must be mathematically rejected if sent to the Loyalty API.
  • Identity Preservation: The downstream Loyalty API must still know which user initiated the original request.
  • Scope Downscoping: Only precise permissions necessary for the downstream call should be delegated.
  • Performance: Exchanging tokens must not add more than 10ms to the request lifecycle.

Visual Architecture

The Core Logic / Process

Data Flow

We implemented the official OAuth 2.0 Token Exchange specification (RFC 8693) using Keycloak as our highly available Authorization Server.

When the React frontend calls the Orders API, it provides a User JWT with the explicit audience (aud) claim set to orders-api. Before the Orders API can talk to the Loyalty API, the Java code interrupts the flow. It sends an HTTP POST back to Keycloak using the urn:ietf:params:oauth:grant-type:token-exchange grant type. It provides its own client credentials, the original User JWT (subject_token), and requests a new token explicitly for the loyalty-api (audience).

Keycloak validates everything, records an audit log, and returns a brand new JWT. This new token retains the user's sub (Subject/User ID) so the Loyalty service knows who the user is, but the aud (Audience) is strictly locked to loyalty-api, and the scopes are downgraded strictly to loyalty:read. The Orders API then sends this new token to the Loyalty API.

Concurrency & Locking

Network calls to Keycloak for every intra-service request would cripple performance in a microservice mesh. To prevent this, our Java 21 Spring Security filter implements a massive concurrent cache (Caffeine). Since the Token Exchange represents a deterministic (User JWT + Target Service = Exchanged JWT), we cache the Exchanged JWT in the Orders API memory until 10 seconds before its expiration. If 1,000 requests from the same user flow through, 999 of them utilize the 1-microsecond local cache rather than calling Keycloak.

Failure Modes

If Keycloak is temporarily down, the Caffeine cache gracefully serves stale Exchanged JWTs if they haven't explicitly expired by their cryptographic strict timestamp. If an exchanged token has expired and Keycloak is down, we must fail the downstream request with a 503 HTTP status. We cannot fall back to passing the raw User JWT, as that fundamentally violates the entire Zero-Trust design.

The "Baigan" Take (Expert Insights)

Look, setting up RFC 8693 sounds like pure architectural purity, but it exposes a massive flaw in how developers write Spring Security configurations.

The baigan truth is that Spring Security is overly permissive by default regarding the aud (Audience) claim if you aren't careful. If you just implement standard JwtDecoder, it often checks the signature and expiration, but ignores whether the token is meant for that specific API.

We had to explicitly write OAuth2TokenValidator<Jwt> classes inside every single microservice that strictly asserted if (!jwt.getAudience().contains("my-specific-service-name")) throw new BadOpaqueTokenException(). If you implement Token Exchange but forget to enforce Audience validation on the receiving end, you've built a very complex, very fast, completely useless system.

Performance Checklist

  • Adopt RFC 8693 Token Exchange rather than blindly forwarding Frontend User JWTs.
  • Explicitly downscope permissions and assert strict aud (Audience) claims during exchange.
  • Implement a Caffeine local cache keyed by the User JWT hash to prevent Auth Server DDoS.
  • Write explicit Spring Security validators to violently reject incoming JWTs whose aud claim doesn't match the current service name.
© 2026 PRANABESH_VAULT_ENCRYPTION_V4CONFIDENTIAL_REPRODUCTION_PROHIBITED