필사 모드: OAuth Token Exchange (RFC 8693) — The Definitive Guide to Delegation and Propagation in Microservices
EnglishIntroduction
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](https://datatracker.ietf.org/doc/html/rfc8693) 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](https://www.keycloak.org/docs/latest/release_notes/index.html). 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.
| Aspect | Impersonation | Delegation |
|--------|---------------|------------|
| Subject of the new token | The user, indistinguishably | The user, with the actor made explicit |
| act claim | Absent | Present (identifies the actor) |
| What downstream sees | Looks like the user called directly | Sees who called on the user's behalf |
| Audit trail | Call chain is lost | Call chain is preserved |
| Risk level | High (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
| Parameter | Required | Description |
|-----------|----------|-------------|
| grant_type | Required | Fixed value (the token-exchange URN) |
| subject_token | Required | The original token being exchanged (usually the user token) |
| subject_token_type | Required | URI identifying the type of subject_token |
| actor_token | Optional | A token representing the actor (the calling service) |
| actor_token_type | Required if actor_token present | Type of the actor_token |
| requested_token_type | Optional | The token type you wish to receive |
| audience | Optional | The target service the new token will be used at (logical name) |
| resource | Optional | URI of the target resource (RFC 8707 style) |
| scope | Optional | Scopes 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](https://www.keycloak.org/docs/latest/release_notes/index.html), 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)](https://www.keycloak.org/docs/latest/release_notes/index.html) 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](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/) 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.
| Pattern | User context | Audience isolation | IdP round trips | Best suited for |
|---------|--------------|--------------------|-----------------|-----------------|
| Forward token as-is | Preserved | None | None | Internal PoCs, temporary setups in a single trust domain |
| Client credentials | Lost | Possible | Yes (cacheable) | Batch jobs, system-to-system work unrelated to a user |
| Token Exchange | Preserved + explicit actor | Strong | Yes (cacheable) | Calls on behalf of a user, audit trail required |
| Transaction Tokens | Preserved + chain preserved | Strong | Once at entry | Large 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
- [RFC 8693 — OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
- [RFC 6749 — The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7523 — JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants](https://datatracker.ietf.org/doc/html/rfc7523)
- [RFC 8707 — Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
- [RFC 9700 — Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)
- [OAuth 2.1 draft (draft-ietf-oauth-v2-1)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)
- [Transaction Tokens (draft-ietf-oauth-transaction-tokens)](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/)
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)
- [Keycloak 26.6.0 Released](https://www.keycloak.org/2026/04/keycloak-2660-released)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [SPIFFE Documentation](https://spiffe.io/docs/)
현재 단락 (1/254)
One of the most common — and most commonly mishandled — problems in microservice architecture is tok...