Skip to content
Published on

OAuth Token Exchange (RFC 8693) — The Definitive Guide to Delegation and Propagation in Microservices

Authors

Introduction

One of the most common — and most commonly mishandled — problems in microservice architecture is token propagation. A user calls service A with the access token obtained on the frontend, and service A in turn needs to call service B. Which token should it carry?

The easiest answer is "just forward the token you received," and in practice many organizations operate exactly that way. But this approach neutralizes audience validation and expands the blast radius of a stolen token to the entire system. It is an anti-pattern.

RFC 8693 OAuth 2.0 Token Exchange was standardized back in 2020, but support from major IdPs lagged, leaving it for a long time in a "the spec exists but you cannot use it" state. The situation has changed fairly recently. Keycloak began officially supporting standard Token Exchange from 26.2, and as of May 2026 the latest 26.6.x line even enables external-to-internal exchange in combination with the JWT Authorization Grant. On top of that, with the explosion of scenarios where AI agents call multiple APIs on behalf of users, the delegation semantics of stating "who acts, on behalf of whom, with which permissions" inside the token have become more important than ever.

In this article we will dissect the RFC 8693 mechanism at the wire level and walk through practical configuration and security design with Keycloak as the reference implementation.

The Token Propagation Problem — Why You Must Not Just Forward Tokens

Let us look at a typical scenario.

+--------+        access_token(aud=order-api)         +-----------+
|  User  | -----------------------------------------> | order-api |
| (SPA)  |                                            | (svc A)   |
+--------+                                            +-----+-----+
                                                            |
                                  forward the same token?   |
                                                            v
                                                      +-----------+
                                                      | payment-  |
                                                      | api (B)   |
                                                      +-----------+

The user token was issued for order-api. If the JWT aud claim points at order-api, then payment-api should reject this token — that is correct behavior. Yet in an architecture that simply forwards tokens, you are left with two bad options.

  1. payment-api disables or loosens audience validation → any token issued for any service passes. You have created a universal token; if one token leaks, the entire system is compromised.
  2. You issue broad tokens with every service in the audience from the start → this violates least privilege, and the token exposed to the frontend carries excessive power.

A second problem is the loss of the acting party. From the perspective of payment-api, it is impossible to distinguish whether "this request was made by the user directly, or by order-api on the user's behalf." If the call chain does not appear in audit logs, satisfying financial-sector compliance requirements becomes very difficult.

Token Exchange tackles this head-on. Service A presents the user token as evidence (subject_token) and exchanges it for a fresh token dedicated to payment-api.

+--------+   (1) access_token(aud=order-api)   +-----------+
|  User  | -----------------------------------> | order-api |
+--------+                                      +-----+-----+
                                                      |
                    (2) token exchange request        |
                    subject_token = user token        v
                                              +---------------+
                                              |   Keycloak    |
                                              | (Auth Server) |
                                              +-------+-------+
                                                      |
                    (3) new token (aud=payment-api,   |
                        act=order-api) issued         v
                                                +-----------+
                                                | payment-  |
                                                | api       |
                                                +-----------+

Impersonation vs Delegation — What the act Claim Means

RFC 8693 distinguishes two semantics. If you do not internalize this distinction, your design will go off the rails.

AspectImpersonationDelegation
Subject of the new tokenThe user, indistinguishablyThe user, with the actor made explicit
act claimAbsentPresent (identifies the actor)
What downstream seesLooks like the user called directlySees who called on the user's behalf
Audit trailCall chain is lostCall chain is preserved
Risk levelHigh (untraceable if abused)Comparatively lower

With impersonation, the sub of the new token is the user and no other trace remains. Downstream services cannot tell it apart from the user arriving directly. With delegation, by contrast, the new token includes the act (actor) claim.

{
  "iss": "https://idp.example.com/realms/prod",
  "sub": "user-1234",
  "aud": "payment-api",
  "exp": 1781234567,
  "scope": "payment:read",
  "act": {
    "sub": "service-order-api"
  }
}

Read this token as: "the subject is user-1234, but the actual actor is service-order-api." When exchanges chain together, act nests inside act, preserving the entire delegation chain.

{
  "sub": "user-1234",
  "aud": "audit-api",
  "act": {
    "sub": "service-payment-api",
    "act": {
      "sub": "service-order-api"
    }
  }
}

The outermost act is the most recent actor. RFC 8693 additionally defines the may_act claim, which declares in advance "the parties authorized to act on behalf of this token's subject." The authorization server can verify that the subject of the actor_token appears in the subject_token's may_act and thereby block unauthorized delegation.

The practical recommendation is clear. Use delegation for service-to-service calls wherever possible. Impersonation should be confined to narrow cases that genuinely must appear as the user, such as an admin support desk.

RFC 8693 Request and Response — A Wire-Level Breakdown

Token Exchange is defined as a single grant type against the token endpoint. The grant_type value is urn:ietf:params:oauth:grant-type:token-exchange.

Request parameters

ParameterRequiredDescription
grant_typeRequiredFixed value (the token-exchange URN)
subject_tokenRequiredThe original token being exchanged (usually the user token)
subject_token_typeRequiredURI identifying the type of subject_token
actor_tokenOptionalA token representing the actor (the calling service)
actor_token_typeRequired if actor_token presentType of the actor_token
requested_token_typeOptionalThe token type you wish to receive
audienceOptionalThe target service the new token will be used at (logical name)
resourceOptionalURI of the target resource (RFC 8707 style)
scopeOptionalScopes requested for the new token (downscoping recommended)

Token type identifiers are expressed as URNs. The frequently used values are:

urn:ietf:params:oauth:token-type:access_token   access token (format agnostic)
urn:ietf:params:oauth:token-type:refresh_token  refresh token
urn:ietf:params:oauth:token-type:id_token       OIDC ID token
urn:ietf:params:oauth:token-type:jwt            a JWT as such (format explicit)
urn:ietf:params:oauth:token-type:saml2          SAML 2.0 assertion

A real HTTP request example

Here order-api exchanges the user token for a payment-api token. Note that client authentication (client_secret_basic here) accompanies the request.

POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.example.com
Authorization: Basic b3JkZXItYXBpOnMzY3IzdA==
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJhbGciOiJSUzI1NiIs...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&audience=payment-api
&scope=payment%3Aread

The response

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "eyJhbGciOiJFUzI1NiIs...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "payment:read"
}

The field unique to RFC 8693 in the response is issued_token_type. Unlike an ordinary token response, it states the type of the issued token. token_type denotes how the token is used at the protocol level (Bearer, DPoP, and so on) — the two are different concepts.

subject_token and actor_token

  • subject_token answers "whom is this token about." It becomes the subject of the new token.
  • actor_token answers "who is acting." In delegation scenarios you put the calling service's own token here (for example one obtained via client credentials).

If actor_token is omitted, the authorization server may treat the client authentication itself as the actor. Keycloak's standard Token Exchange also uses client authentication as the means of identifying the actor. If you need explicit actor_token-based delegation, verify your IdP's level of support first.

Token Exchange in Keycloak — From Legacy to Standard

Token Exchange in Keycloak has a long history. Let us go through it stage by stage.

Legacy Token Exchange (preview, up to 26.1)

For a long time Keycloak's Token Exchange was a preview feature requiring a feature flag.

bin/kc.sh start --features=token-exchange,admin-fine-grained-authz

The legacy approach was coupled with the fine-grained admin permissions model. It covered a wide range — internal-to-internal, internal-to-external, external-to-internal, and impersonation — but earned a reputation for incomplete spec compliance and convoluted configuration.

Standard Token Exchange (officially supported from 26.2)

Starting with Keycloak 26.2, an RFC 8693-compliant standard Token Exchange (V2) is supported. The key characteristics are:

  • internal-to-internal exchange: swap a token issued by the same realm for one targeting a different client/audience
  • per-client activation: enabled via the Standard token exchange toggle under the client's Capability config in the Admin Console
  • confidential clients required: the client requesting the exchange must be able to authenticate
  • target client designated via the audience parameter, with scope downscoping supported

Here is how to enable it through the Admin CLI.

# enable standard token exchange for a client
kcadm.sh update clients/CLIENT-UUID -r prod \
  -s 'attributes."standard.token.exchange.enabled"="true"'

# verify
kcadm.sh get clients/CLIENT-UUID -r prod --fields clientId,attributes

The exchange request itself is exactly the standard HTTP request shown above. Keycloak composes the claims of the new token according to the scope and mapper configuration of the client designated by audience.

External-to-internal and the 26.6 JWT Authorization Grant

External-to-internal exchange — converting a token issued by an external IdP (say, a partner's authorization server) into a Keycloak token — is trickier because it requires a trust federation. Keycloak 26.6 introduced the JWT Authorization Grant (based on RFC 7523) to solve this path in a standards-based way. You submit the external issuer's JWT as an assertion, and Keycloak validates it against its trust configuration (the identity provider's issuer and signing keys) and issues its own token.

POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
&assertion=eyJhbGciOiJSUzI1NiIs...
&client_id=partner-gateway
&scope=order%3Aread

Combined with 26.6's Federated client authentication (clients authenticating themselves with externally issued JWTs), you can build a trust chain with partner systems without sharing secrets. Integration with workload identities such as SPIFFE/SVID also falls out naturally on top of this pattern.

Audience Restriction and Least-Privilege Design

The value of Token Exchange lies in being able to shave down privileges at every exchange. The design principles:

  1. One audience per token. The aud of an exchanged token should point at exactly one next-hop service.
  2. Always downscope. Even if the user token carries ten scopes, if the payment-api call needs only payment:read, request only that. Under RFC 8693 the authorization server must not grant broader privileges than requested.
  3. Keep exchanged token lifetimes short. The time needed for one downstream call (tens of seconds to a few minutes) is enough. Not issuing a refresh token is the default posture.
  4. Manage an explicit exchange matrix. Enforce per-client allowed targets as IdP policy, such as "order-api may exchange only for payment-api tokens."

Every receiving service must validate the following.

// Spring Security: add explicit audience validation
@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(
        "https://idp.example.com/realms/prod");

    OAuth2TokenValidator<Jwt> withIssuer =
        JwtValidators.createDefaultWithIssuer(
            "https://idp.example.com/realms/prod");
    OAuth2TokenValidator<Jwt> withAudience = new JwtClaimValidator<List<String>>(
        "aud", aud -> aud != null && aud.contains("payment-api"));

    decoder.setJwtValidator(
        new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
    return decoder;
}

A resource server that skips audience validation gains no security benefit from Token Exchange. The exchange infrastructure and hardened validation must always come as a set.

Exchange Patterns at the API Gateway

Where to perform the exchange is an architectural decision. There are two patterns.

Pattern 1: gateway-centric
+------+    user token    +---------+   exchanged token   +----------+
| User | ---------------> | Gateway | ------------------> | Service  |
+------+                  |         | --+                 |    A     |
                          +---------+   |  exchanged      +----------+
                               |        +---------------> +----------+
                       (exchanges w/ IdP)                 | Service B|
                                                          +----------+

Pattern 2: service-distributed
+------+   user token   +---------+  user token  +-----------+
| User | -------------> | Gateway | -----------> | Service A |
+------+                +---------+              +-----+-----+
                                                       | A exchanges itself
                                                       v
                                                 +-----------+
                                                 | Service B |
                                                 +-----------+

The gateway-centric pattern concentrates exchange logic and IdP credentials in one place, simplifying management; each service receives only its own token. The downsides are that the gateway must know every downstream path, and a gateway compromise has a large impact. The service-distributed pattern is faithful to least privilege — each service exchanges only what it needs, when it needs it — but every service must be registered as a confidential client and carry exchange code.

In practice a hybrid is common: the gateway handles authentication and the first audience narrowing, and each additional inter-service hop is exchanged by the calling service. An exchange call from a Spring gateway looks like this.

public String exchangeForAudience(String subjectToken, String audience) {
    MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
    form.add("grant_type",
        "urn:ietf:params:oauth:grant-type:token-exchange");
    form.add("subject_token", subjectToken);
    form.add("subject_token_type",
        "urn:ietf:params:oauth:token-type:access_token");
    form.add("requested_token_type",
        "urn:ietf:params:oauth:token-type:access_token");
    form.add("audience", audience);

    return webClient.post()
        .uri(tokenEndpoint)
        .headers(h -> h.setBasicAuth(clientId, clientSecret))
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .bodyValue(form)
        .retrieve()
        .bodyToMono(TokenResponse.class)
        .map(TokenResponse::accessToken)
        .block();
}

Caching exchange results within the remaining lifetime of the subject_token dramatically reduces IdP load. Use a cache key composed of the subject_token hash plus audience plus scope.

Transaction Tokens — The Next Step in Standardization

If the cost of a round trip to the IdP at every hop becomes a burden in deep call graphs, keep an eye on the Transaction Tokens draft in progress at the IETF OAuth WG. The core ideas:

  • Exchange the external token (the user access token) exactly once at the trust-boundary entry point, at a Transaction Token Service, for a short-lived internal token (txn token)
  • The txn token carries the purpose of the call, the request context, and the delegation chain, and circulates only among internal microservices
  • The exchange protocol itself is defined as a profile of RFC 8693

In other words, it uses Token Exchange as the foundational primitive, optimized for internal traffic. As of 2026 it is still a draft, but referencing its claim structure (txn, purp, rctx, azd, and so on) when designing internal standards buys you forward compatibility.

Security Pitfalls in Implementation

These are the landmines teams actually step on during adoption.

  1. Over-granting exchange permissions. If every service can exchange for every audience, Token Exchange becomes a token-laundering machine. An attacker holding one stolen token can pivot across the whole system by swapping audiences. Keep the exchange matrix minimal and audit it regularly.
  2. Weak subject_token validation. The authorization server must check the subject_token's signature, expiry, issuer, and — where possible — revocation status. A configuration that swaps a nearly expired token for a long-lived one is effectively a lifetime-extension bypass.
  3. Allowing public clients to exchange. The exchange requester must be authenticated. Letting SPAs or mobile apps exchange directly renders actor authentication meaningless.
  4. Not validating the act chain. If downstream services do not validate the act claim, the audit value of delegation evaporates. Sensitive APIs should include an allow-list of permitted actors in their validation logic.
  5. Token exposure in logs. The exchange request body contains tokens in plaintext. Check that gateway/IdP access logs and APM traces do not record form bodies.
  6. Refresh token over-issuance. If exchanged tokens come with refresh tokens, the delegation becomes effectively permanent. In Keycloak you can disable refresh token issuance for exchange responses by policy.

Comparison with Alternative Patterns

Token Exchange is not always the right answer. Let us establish selection criteria against the alternatives.

PatternUser contextAudience isolationIdP round tripsBest suited for
Forward token as-isPreservedNoneNoneInternal PoCs, temporary setups in a single trust domain
Client credentialsLostPossibleYes (cacheable)Batch jobs, system-to-system work unrelated to a user
Token ExchangePreserved + explicit actorStrongYes (cacheable)Calls on behalf of a user, audit trail required
Transaction TokensPreserved + chain preservedStrongOnce at entryLarge internal meshes with many hops

The decision rule is simple. Does the downstream operation depend on this specific user's permissions? If yes, client credentials is inappropriate (it would bypass user authorization) and Token Exchange is correct. If the work is user-independent system activity, client credentials is simpler and the better fit. Do not use as-is forwarding beyond a temporary migration crutch.

Troubleshooting Notes

Common errors and their causes when adopting Keycloak standard Token Exchange.

Error response                     Primary cause
---------------------------------------------------------------
invalid_request                   subject_token_type missing/typo,
                                  unsupported token-type URN
invalid_client                    exchange client failed to
                                  authenticate, or request from
                                  a public client
unauthorized_client               standard token exchange not
                                  enabled for this client
invalid_target (or invalid_scope) audience client does not exist
                                  or exchange policy forbids it
access_denied                     the subject_token user lacks a
                                  role granting access to the
                                  target client

An efficient debugging order: (1) check the exchange client's Capability config, (2) check the audience client exists and its scope mappings, (3) inspect Keycloak server events (TOKEN_EXCHANGE, TOKEN_EXCHANGE_ERROR). Keeping event logging on lets you trace every exchange attempt, which also helps with audits.

# enable token exchange events in event settings, then query
kcadm.sh get events -r prod -q type=TOKEN_EXCHANGE_ERROR

Closing Thoughts

Token Exchange is the standard answer to the long-standing microservices homework of "how do we hand tokens around." To summarize the essentials:

  • Forwarding tokens as-is is an anti-pattern that destroys audience isolation. Exchange for a target-specific token at every hop.
  • Make delegation (the act claim) your default so the call chain is preserved inside the token. Keep impersonation to a minimum.
  • Keycloak supports standard exchange from 26.2 and external federation via the JWT Authorization Grant from 26.6. It is time to migrate off the legacy preview feature.
  • A minimal exchange matrix, scope downscoping, short lifetimes, and hardened audience validation come as one package.
  • If you have many internal hops, fold the direction of the transaction tokens draft into your design early.

In an era where AI agents act on behalf of users, the ability to cryptographically prove "who is acting for whom" is no longer optional — it is table stakes. RFC 8693 is the standard grammar of that capability.

References