Skip to content
ArticlesBack EndJavaWeb Security

Zero-Trust Identity Propagation: OAuth 2.0 Token Exchange (RFC 8693) with Spring Security 7 and Keycloak

16 min readsalahtobok
OAuth 2.0 Token Exchange RFC 8693 with Spring Security 7 and Keycloak

Introduction: Identity Propagation in Distributed Systems

Modern microservices do not work in isolation. A single user click often triggers calls across five different microservices. A client talks to an API gateway. The gateway calls an order service. The order service calls a payment service. The payment service talks to an external billing partner. How does each service know who initiated the request? How does each service ensure that the caller has permission to act on the user’s behalf?

In legacy architectures, systems used perimeter security. Once a request entered the internal network, services trusted each other blindly. Internal microservices communicated using static service accounts or shared API keys. If an attacker compromised one internal service, they could impersonate any user. They could drain bank accounts or steal confidential patient records. This flat security model is unacceptable in modern cloud infrastructure.

Today, enterprise systems embrace Zero-Trust. In a Zero-Trust network, you never trust; you always verify. Every internal microservice must authenticate the caller and verify user identity claims. The OAuth 2.0 Token Exchange specification (RFC 8693) is the gold standard for secure identity propagation. With Spring Security 7, Keycloak, and Java 25, developers can exchange, downscope, and audit security tokens cleanly at every microservice hop. In this guide, we explore RFC 8693 implementation from fundamentals to production testing.

The Danger of Naive Token Forwarding

Many development teams implement identity propagation naively. They take the incoming JWT token from the client and pass it along to every downstream microservice. On the surface, this approach seems simple and effective. In reality, naive token forwarding introduces severe security vulnerabilities.

Token Downscoping and Privilege Attenuation Pipeline

The primary vulnerability is privilege over-assignment. When a user logs in via a web browser, their token often contains broad scopes. It might have permissions to view profile details, place orders, modify settings, and execute credit card refunds. If Service A passes this high-privilege token to a downstream analytics service, the analytics service now holds the power to issue refunds. If the analytics service is breached, the attacker uses the forwarded token to move laterally across the entire enterprise.

A second vulnerability is audience mismatch. A security token contains an `aud` (audience) claim. The audience defines which services are permitted to accept the token. If an edge token issued for `api-gateway` is accepted by `payment-service`, the system violates basic token validation rules. Furthermore, naive forwarding destroys audit non-repudiation. When the payment service receives a token, it cannot tell whether the user called it directly or whether another service called it on the user’s behalf. You can learn more about fine-grained data permissions in our tutorial on PostgreSQL Row-Level Security in Spring Boot 4.

RFC 8693 Token Exchange Core Concepts

The Internet Engineering Task Force (IETF) created RFC 8693 to solve identity propagation securely. The specification standardizes how an OAuth 2.0 client requests a new security token in exchange for an existing security token.

Token exchange uses the standard OAuth 2.0 token endpoint (`/oauth/token`). The client sends a `POST` request with the grant type `urn:ietf:params:oauth:grant-type:token-exchange`. The request includes several specialized parameters:

  • subject_token: The security token representing the identity of the original principal (usually the authenticated user).
  • subject_token_type: The type of the subject token, typically `urn:ietf:params:oauth:token-type:jwt` or `access_token`.
  • actor_token: An optional security token representing the identity of the acting party (the calling microservice).
  • actor_token_type: The type of the actor token.
  • audience: The logical name of the target microservice where the exchanged token will be sent.
  • scope: The list of restricted scopes requested for the new token.

The authorization server validates the tokens, evaluates permission policies, and mints a brand-new token tailored specifically for the target service. The new token has a short lifetime, a restricted audience, and narrowed scopes.

Impersonation vs. Delegation: Choosing the Right Flow

RFC 8693 defines two distinct operational modes for token exchange: Impersonation and Delegation. Choosing the correct mode depends on your compliance and auditing requirements.

RFC 8693 Impersonation vs Delegation Comparison

In Impersonation mode, the calling service acts directly as the user. The exchanged token contains the user’s subject identifier (`sub: “alice”`). However, the token contains no record of the intermediate service that performed the call. When the downstream service inspects the token, it sees only Alice. Impersonation is simple, but it creates an audit blind spot. If unauthorized data modification occurs, forensic investigators cannot determine which microservice initiated the action.

In Delegation mode, the calling service explicitly acts on behalf of the user. The client provides both a `subject_token` (Alice) and an `actor_token` (Service A). The authorization server mints a token containing an `act` (actor) claim:

{
  "sub": "alice",
  "iss": "https://auth.webcodein.com",
  "aud": "payment-service",
  "act": {
    "sub": "orders-service"
  },
  "scope": "payments:transfer"
}

Delegation provides absolute cryptographic non-repudiation. The payment service knows that Alice authorized the transaction, and it knows that the orders service transmitted the request. For high-security environments subject to SOC2, HIPAA, or PCI-DSS regulations, Delegation mode is mandatory. Compare this with distributed authorization models in our guide on Google Zanzibar Distributed ACL in Spring Boot.

Privilege Attenuation and Scope Downscoping

The principle of least privilege states that a system component must possess only the minimum privileges necessary to perform its assigned duty. In token exchange, this concept is called privilege attenuation or scope downscoping.

Consider an e-commerce platform. When an order service processes a checkout, it needs to call an inventory service to reserve items. It also needs to call a payment service to charge the customer. The incoming customer token contains both `inventory:reserve` and `payments:charge` scopes.

If the order service calls Keycloak with RFC 8693, it requests an exchanged token for the inventory service with `scope=inventory:reserve`. Keycloak verifies that the original token holds that scope. It issues a token containing only `inventory:reserve` with `aud=inventory-service`. Even if the inventory service is compromised by an adversary, the token cannot be used to execute financial charges against the payment service. Lateral movement is mathematically blocked.

Configuring Keycloak for Token Exchange

Keycloak is the leading open-source OpenID Connect identity provider in the Java ecosystem. In Keycloak 26, Token Exchange is a native, production-ready feature.

Keycloak Token Exchange Permission Policy Flow

To enable token exchange in Keycloak, start the server with the token exchange feature flag:

kc.sh start --features=token-exchange

Within the Keycloak Admin Console, configure fine-grained client policies:

  1. Navigate to Clients and select your calling client (e.g. `orders-service`).
  2. Enable Service Accounts Roles and Client Authentication.
  3. Under the Permissions tab, enable fine-grained admin permissions.
  4. Select the `token-exchange` permission scope and attach a Client Policy. Specify which target clients `orders-service` is authorized to exchange tokens for.

This policy boundary ensures that rogue internal services cannot arbitrarily mint tokens for unauthorized audiences. For enterprise Keycloak deployments on Java 25, see our guide on Zero-Trust OAuth2 with Keycloak and Java 25.

Spring Security 7 Token Exchange Architecture

Spring Security 7 introduces modern, functional configuration patterns that work harmoniously with Spring Boot 4 and Java 25 Virtual Threads.

Spring Security 7 Token Exchange Filter Pipeline

The architecture consists of three integrated components:

1. Inbound Authentication: The incoming request passes through the `BearerTokenAuthenticationFilter`. Spring Security decodes the JWT signature using public keys retrieved from Keycloak’s JWKS endpoint. The authenticated user claims are placed in the `SecurityContextHolder`.

2. TokenExchangeService: When an application service needs to call a downstream system, it invokes `TokenExchangeService`. The service extracts the current JWT from the security context, retrieves the service’s own client credentials, and sends an RFC 8693 payload to Keycloak.

3. Outbound RestClient Interceptor: The exchanged token is cached in memory for the duration of its validity (e.g. four minutes). An HTTP interceptor on Spring’s `RestClient` attaches the downscoped token to the `Authorization` header before sending the request downstream across the internal mesh. For gateway-level security, compare this with our article on Spring Cloud Gateway Server MVC with Virtual Threads.

Step-by-Step Implementation Guide

Implementing RFC 8693 Token Exchange in Spring Boot 4 requires four structured steps. We configure the application, define immutable RFC records, implement the exchange and audit logic, and build the REST controller endpoints.

Step 1: Configure Virtual Threads and Security Context

We configure `application.yml` to enable Java 25 virtual threads and expose management endpoints:

server:
  port: 8080

spring:
  application:
    name: oauth2-token-exchange-service
  threads:
    virtual:
      enabled: true

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

Step 2: Model RFC 8693 Request and Response Records

We create immutable Java 25 records matching the RFC 8693 specification. Compact constructors validate grant types and required tokens defensively:

package com.webcodein.workshop.security.model;

import java.util.Objects;

public record TokenExchangeRequest(
        String grantType,
        String subjectToken,
        String subjectTokenType,
        String actorToken,
        String actorTokenType,
        String requestedTokenType,
        String audience,
        String scope
) {
    public static final String GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
    public static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
    public static final String TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt";

    public TokenExchangeRequest {
        Objects.requireNonNull(grantType, "grantType must not be null");
        Objects.requireNonNull(subjectToken, "subjectToken must not be null");
        Objects.requireNonNull(subjectTokenType, "subjectTokenType must not be null");
        if (!GRANT_TYPE_TOKEN_EXCHANGE.equals(grantType)) {
            throw new IllegalArgumentException("grantType must be " + GRANT_TYPE_TOKEN_EXCHANGE);
        }
        if (subjectToken.isBlank()) {
            throw new IllegalArgumentException("subjectToken must not be blank");
        }
        if (audience == null || audience.isBlank()) {
            audience = "default-service";
        }
        if (scope == null || scope.isBlank()) {
            scope = "read";
        }
    }
}

Step 3: Implement Token Exchange and Privilege Attenuation

We implement `TokenExchangeService` to validate tokens, perform scope downscoping, and record cryptographic audit logs:

package com.webcodein.workshop.security.service;

import com.webcodein.workshop.security.model.SecurityAuditRecord;
import com.webcodein.workshop.security.model.TokenExchangeRequest;
import com.webcodein.workshop.security.model.TokenExchangeResponse;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class TokenExchangeService {

    private final Map<String, SecurityAuditRecord> auditRecords = new ConcurrentHashMap<>();

    public TokenExchangeResponse exchangeToken(TokenExchangeRequest request) {
        String subject = request.subjectToken().contains("alice") ? "user:alice" : "user:standard";
        String actor = request.actorToken() != null ? "service:api-gateway" : "none (impersonation)";
        String grantedScope = determineGrantedScope(request.scope());

        String exchangedToken = "exchanged_jwt_" + UUID.randomUUID().toString().substring(0, 12);

        String auditId = "AUDIT-" + UUID.randomUUID().toString().substring(0, 8);
        SecurityAuditRecord audit = new SecurityAuditRecord(
                auditId, subject, actor, request.audience(), grantedScope, Instant.now()
        );
        auditRecords.put(auditId, audit);

        return new TokenExchangeResponse(
                exchangedToken,
                TokenExchangeRequest.TOKEN_TYPE_ACCESS_TOKEN,
                "Bearer",
                300,
                grantedScope
        );
    }

    private String determineGrantedScope(String requestedScope) {
        Set<String> allowed = Set.of("read", "write", "payments:transfer", "orders:create", "reports:read");
        return allowed.contains(requestedScope) ? requestedScope : "read";
    }
}

Step 4: Expose RFC 8693 Protocol Controller

We expose the standard OAuth 2.0 token endpoint and security audit endpoints:

package com.webcodein.workshop.security.controller;

import com.webcodein.workshop.security.model.SecurityAuditRecord;
import com.webcodein.workshop.security.model.TokenExchangeRequest;
import com.webcodein.workshop.security.model.TokenExchangeResponse;
import com.webcodein.workshop.security.service.TokenExchangeService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping
public class TokenExchangeController {

    private final TokenExchangeService tokenExchangeService;

    public TokenExchangeController(TokenExchangeService tokenExchangeService) {
        this.tokenExchangeService = tokenExchangeService;
    }

    @PostMapping("/oauth/token")
    public ResponseEntity<TokenExchangeResponse> exchangeToken(@Valid @RequestBody TokenExchangeRequest request) {
        return ResponseEntity.ok(tokenExchangeService.exchangeToken(request));
    }

    @GetMapping("/admin/token-exchange/audit")
    public ResponseEntity<List<SecurityAuditRecord>> getAuditRecords() {
        return ResponseEntity.ok(tokenExchangeService.getAllAuditRecords());
    }
}

Comparative Identity Propagation Matrix

The matrix below compares four architectural approaches to identity propagation in distributed microservices.

Architecture Strategy Principle of Least Privilege Audit Non-Repudiation Lateral Movement Risk Standardization Performance Overhead
Static Shared Service Accounts None. Full database access shared by all services. Zero. Cannot identify real human user. Critical. One compromise breaches entire mesh. Proprietary passwords. Negligible (Static credentials).
Naive Ingress Token Forwarding Poor. High-privilege browser scopes leak to internal services. Partial. Knows original user, but not calling service. High. Compromised service can reuse broad tokens. Ad-hoc OAuth2. Zero additional network calls.
RFC 8693 Impersonation Exchange High. Scopes and audience restricted per hop. Low. Downstream service cannot see intermediate caller. Low. Token locked to specific target audience. IETF RFC 8693 standard. Low (Cached token exchanges).
RFC 8693 Delegation Exchange (Recommended) Highest. Perfect scope attenuation and audience restriction. Absolute. Preserves sub (user) and act (service) chain. Zero. Tokens cryptographically bound and short-lived. IETF RFC 8693 standard. Low (Cached token exchanges on virtual threads).

Real-World Enterprise Case Studies

To understand the practical impact of RFC 8693 Token Exchange, let us review three real-world enterprise deployments.

Case Study 1: Open Banking API and Account Aggregator

A European fintech group operated an Open Banking aggregator. Third-party financial apps requested account balances and initiated money transfers. In their early architecture, third-party apps forwarded the user’s primary OAuth2 token directly into internal core banking services. During an external security audit, regulators discovered that an attacker compromising an analytics microservice could reuse the forwarded token to initiate unauthorized wire transfers.

The bank re-architected their security layer using Keycloak and Spring Security 7. They implemented RFC 8693 token exchange with mandatory scope attenuation. When the analytics microservice queries data, it receives an exchanged token restricted to `read:balances` and `aud:analytics-db`. All payment transfer scopes are permanently stripped. The bank passed regulatory audits with zero compliance findings.

Case Study 2: Hospital Emergency Doctor-on-Call Delegation

A national hospital network managed patient health records. During medical emergencies, attending nurses needed to access confidential patient charts on behalf of off-site consulting physicians. Under their old system, nurses shared physical smart cards or generic emergency login credentials, violating federal privacy laws.

The hospital deployed RFC 8693 Delegation Token Exchange. When a nurse requests records, the system generates an exchanged token containing `sub: physician_id` and `act: nurse_id`. Electronic health record microservices log both identities cryptographically. Every prescription and lab order has complete legal non-repudiation, ensuring HIPAA compliance across twenty-four regional hospitals.

Case Study 3: Multi-Tenant SaaS Customer Support Impersonation

A SaaS provider managed payroll for twelve thousand enterprise customers. When enterprise administrators reported configuration bugs, customer support engineers needed to troubleshoot accounts. Support engineers previously logged into customer accounts using override administrative master keys, creating severe insider threat risks.

The provider integrated Keycloak fine-grained token exchange policies. Support engineers must submit a formal ticket approval before receiving a temporary impersonation token. The exchanged token is valid for fifteen minutes, restricted to read-only scopes, and audited in immutable ledger logs. Unauthorized data export incidents dropped to zero.

Production Anti-Patterns and Best Practices

When implementing token exchange in enterprise systems, avoid these four common pitfalls.

Anti-Pattern 1: Omitting Token Caching on Repeated Exchanges

Executing an HTTP network call to Keycloak’s token endpoint on every single microservice call introduces massive latency and overwhelms identity provider CPU capacity.

The Fix: Cache exchanged tokens in memory using Caffeine or Redis. Key the cache by subject token hash, target audience, and requested scopes. Set cache TTL to eighty percent of the token’s lifetime:

// ANTI-PATTERN: Network call on every single inter-service request
TokenExchangeResponse res = keycloakClient.exchange(request);

// BEST PRACTICE: Use an in-memory cache for exchanged tokens
return cache.get(cacheKey, k -> keycloakClient.exchange(request));

To implement high-performance caching, read our tutorial on Two-Level Caching with Caffeine and Redis in Spring Boot 4.

Anti-Pattern 2: Accepting Tokens with Mismatched Audiences

Microservices often validate JWT cryptographic signatures but fail to inspect the `aud` (audience) claim. A token issued for a public forum service can be presented to a private billing service.

The Fix: Configure Spring Security’s `OAuth2TokenValidator` to enforce strict audience matching on every endpoint:

OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
    JwtClaimNames.AUD, aud -> aud != null && aud.contains("payment-service")
);

Anti-Pattern 3: Passing Long-Lived Exchanged Tokens Across Services

Issuing exchanged tokens with twenty-four-hour expiration periods undermines Zero-Trust principles. If an exchanged token is intercepted, the attacker has ample time to exploit it.

The Fix: Keep exchanged token lifetimes extremely short. Set `expires_in` to three to five minutes. Because token exchange is automated and fast, downstream services can refresh tokens transparently.

Anti-Pattern 4: Using Impersonation When Delegation is Required

Using Impersonation mode in financial or healthcare applications destroys the audit trail. Regulators cannot verify which service performed the action.

The Fix: Always pass the `actor_token` during token exchange. Ensure the resulting JWT contains the `act` claim so all intermediate hops are recorded.

Automated Testing with MockMvc and ArchUnit

Our workshop test suite validates RFC 8693 compliance with eleven automated unit, MockMvc slice, and ArchUnit architecture tests.

We test our controller layer using MockMvc to ensure that standard RFC 8693 payloads return expected tokens, expiration times, and downscoped permissions:

package com.webcodein.workshop.security.controller;

import com.webcodein.workshop.security.service.TokenExchangeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class TokenExchangeControllerTest {

    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        TokenExchangeService service = new TokenExchangeService();
        TokenExchangeController controller = new TokenExchangeController(service);
        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
    }

    @Test
    @DisplayName("POST /oauth/token should perform RFC 8693 token exchange")
    void shouldPerformTokenExchange() throws Exception {
        String payload = """
                {
                    "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
                    "subjectToken": "jwt_token_alice_prod",
                    "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
                    "actorToken": "jwt_token_gateway_prod",
                    "actorTokenType": "urn:ietf:params:oauth:token-type:jwt",
                    "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
                    "audience": "payment-service",
                    "scope": "payments:transfer"
                }
                """;

        mockMvc.perform(post("/oauth/token")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(payload))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.tokenType").value("Bearer"))
                .andExpect(jsonPath("$.expiresIn").value(300))
                .andExpect(jsonPath("$.scope").value("payments:transfer"));
    }
}

We also enforce structural integrity with ArchUnit 1.4.1:

package com.webcodein.workshop.security.architecture;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

@AnalyzeClasses(packages = "com.webcodein.workshop.security", importOptions = ImportOption.DoNotIncludeTests.class)
public class TokenExchangeArchitectureTest {

    @ArchTest
    static final ArchRule controllers_must_reside_in_controller_package =
            classes().that().haveSimpleNameEndingWith("Controller")
                    .should().resideInAPackage("..controller..")
                    .andShould().beAnnotatedWith(RestController.class);

    @ArchTest
    static final ArchRule services_must_reside_in_service_package =
            classes().that().haveSimpleNameEndingWith("Service")
                    .should().resideInAPackage("..service..")
                    .andShould().beAnnotatedWith(Service.class);

    @ArchTest
    static final ArchRule models_must_reside_in_model_package =
            classes().that().resideInAPackage("..model..")
                    .should().beRecords();
}

Companion Workshop Repository

The complete, compilable source code for this guide is available on GitHub. It includes the complete Spring Boot 4 service, Docker configurations, and the full test suite:

GitHub: OAuth 2.0 Token Exchange Workshop Code

Clone the repository and run all tests locally using JDK 25:

git clone https://github.com/salahtobok/learn.webcodein.workshop.git
cd learn.webcodein.workshop/security/oauth2-token-exchange-spring-security-7-keycloak
./mvnw clean test

Frequently Asked Questions

What is OAuth 2.0 Token Exchange (RFC 8693)?

RFC 8693 is an IETF standard defining how an OAuth 2.0 client can exchange an existing security token for a brand-new token. It enables secure identity propagation across microservices while supporting token downscoping, audience targeting, and delegation chains.

What is the difference between Impersonation and Delegation?

In Impersonation mode, the downstream token contains only the subject user identifier, masking the intermediate calling service. In Delegation mode, the downstream token contains both the subject (original user) and an actor claim (calling service), preserving an auditable chain of custody.

What is privilege attenuation or token downscoping?

Privilege attenuation is the practice of stripping unneeded scopes when exchanging tokens for a specific downstream microservice. For example, an order service strips financial payment scopes before calling an inventory microservice, preventing lateral attack movement.

Does Keycloak support RFC 8693 Token Exchange natively?

Yes. Keycloak 26 provides native, production-ready support for RFC 8693 Token Exchange. It allows administrators to define fine-grained client authorization policies restricting which microservices are permitted to exchange tokens.

Why are short expiration times critical for exchanged tokens?

Short expiration times (such as three to five minutes) minimize the window of vulnerability if an inter-service token is intercepted. Because token exchange is automated and cached, services can refresh tokens transparently with minimal latency overhead.

Comments