Skip to content

필사 모드: OAuth Token Exchange (RFC 8693) — The Definitive Guide to Delegation and Propagation in Microservices

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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](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...

작성 글자: 0원문 글자: 19,330작성 단락: 0/254