Skip to content
Published on

OAuth Token Exchange(RFC 8693) — 마이크로서비스 위임과 전파의 정석

Authors

들어가며

마이크로서비스 아키텍처에서 가장 흔하면서도 가장 잘못 풀리는 문제 중 하나가 바로 토큰 전파(token propagation)입니다. 사용자가 프런트엔드에서 받은 액세스 토큰으로 서비스 A를 호출했는데, 서비스 A가 다시 서비스 B를 호출해야 한다면 어떤 토큰을 들고 가야 할까요?

가장 쉬운 답은 "받은 토큰을 그대로 넘긴다"이고, 실제로 많은 조직이 그렇게 운영하고 있습니다. 하지만 이 방식은 audience 검증을 무력화하고, 토큰 탈취 시 피해 범위를 시스템 전체로 확장시키는 안티패턴입니다.

RFC 8693 OAuth 2.0 Token Exchange는 2020년에 표준화되었지만, 주요 IdP의 지원이 늦어 오랫동안 "스펙은 있는데 쓸 수 없는" 상태였습니다. 상황이 바뀐 것은 비교적 최근입니다. Keycloak이 26.2부터 표준 Token Exchange를 정식 지원하기 시작했고, 2026년 5월 현재 최신인 26.6.x에서는 JWT Authorization Grant와 연계한 external-to-internal 교환까지 가능해졌습니다. 또한 AI 에이전트가 사용자를 대신해 여러 API를 호출하는 시나리오가 폭증하면서, "누가, 누구를 대신해, 어떤 권한으로" 행동하는지를 토큰에 명시하는 delegation 시맨틱이 그 어느 때보다 중요해졌습니다.

이 글에서는 RFC 8693의 메커니즘을 와이어 레벨에서 분해하고, Keycloak 기준 실전 설정과 보안 설계까지 정리합니다.

토큰 전파 문제 — 왜 그냥 넘기면 안 되는가

전형적인 시나리오를 그림으로 보겠습니다.

+--------+        access_token(aud=order-api)         +-----------+
|  User  | -----------------------------------------> | order-api |
| (SPA)  |                                            | (서비스 A) |
+--------+                                            +-----+-----+
                                                            |
                                      같은 토큰을 그대로 전달? |
                                                            v
                                                      +-----------+
                                                      | payment-  |
                                                      | api (B)   |
                                                      +-----------+

사용자의 토큰은 order-api를 위해 발급된 것입니다. JWT의 aud 클레임이 order-api로 지정되어 있다면, payment-api는 이 토큰을 거부해야 정상입니다. 그런데 토큰을 그대로 전달하는 구조에서는 두 가지 나쁜 선택지만 남습니다.

  1. payment-api가 audience 검증을 끄거나 느슨하게 한다 → 어떤 서비스용 토큰이든 다 통과하는 만능 토큰이 됩니다. 토큰 하나가 유출되면 전체 시스템이 뚫립니다.
  2. 처음부터 모든 서비스를 audience에 넣은 광역 토큰을 발급한다 → 최소 권한 원칙 위반이며, 프런트엔드에 노출되는 토큰의 권한이 과도해집니다.

또 하나의 문제는 행위 주체의 소실입니다. payment-api 입장에서 "이 요청은 사용자가 직접 한 것인가, order-api가 사용자를 대신해 한 것인가"를 구분할 수 없습니다. 감사 로그(audit log)에 호출 체인이 남지 않으면 금융권 컴플라이언스 요구사항을 충족하기 어렵습니다.

Token Exchange는 이 문제를 정면으로 해결합니다. 서비스 A가 사용자의 토큰을 증거(subject_token)로 제시하고, payment-api 전용의 새 토큰으로 교환받는 것입니다.

+--------+   (1) access_token(aud=order-api)   +-----------+
|  User  | -----------------------------------> | order-api |
+--------+                                      +-----+-----+
                                                      |
                    (2) token exchange 요청           |
                    subject_token = 사용자 토큰        v
                                              +---------------+
                                              |   Keycloak    |
                                              | (Auth Server) |
                                              +-------+-------+
                                                      |
                    (3) 새 토큰(aud=payment-api,       |
                        act=order-api) 발급            v
                                                +-----------+
                                                | payment-  |
                                                | api       |
                                                +-----------+

Impersonation vs Delegation — act 클레임의 의미

RFC 8693은 두 가지 시맨틱을 구분합니다. 이 구분을 이해하지 못하면 설계가 산으로 갑니다.

구분Impersonation(가장)Delegation(위임)
새 토큰의 주체사용자 본인 그 자체사용자, 단 행위자가 명시됨
act 클레임없음있음 (행위자 식별)
다운스트림이 보는 것사용자가 직접 호출한 것처럼 보임누가 대신 호출했는지 보임
감사 추적호출 체인 소실호출 체인 보존
위험도높음 (오남용 시 추적 불가)상대적으로 낮음

Impersonation은 새 토큰의 sub가 사용자이고 그 외 흔적이 없습니다. 다운스트림 서비스는 사용자가 직접 온 것과 구분할 수 없습니다. 반면 delegation에서는 새 토큰에 act(actor) 클레임이 포함됩니다.

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

위 토큰은 "주체는 user-1234이지만, 실제 행위자는 service-order-api"라고 읽습니다. 교환이 체인으로 이어지면 act 안에 또 act가 중첩되어 전체 위임 체인이 보존됩니다.

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

가장 바깥 act가 가장 최근 행위자입니다. RFC 8693은 추가로 may_act 클레임도 정의하는데, 이는 "이 토큰의 주체를 대신해 행동할 수 있도록 허가된 당사자"를 미리 선언하는 용도입니다. 인가 서버는 actor_token의 주체가 subject_token의 may_act에 명시된 당사자인지 검증해 무단 위임을 차단할 수 있습니다.

실무 권고는 명확합니다. 서비스 간 호출에는 가능한 한 delegation을 사용하십시오. impersonation은 관리자 지원 데스크처럼 정말로 사용자로 보여야 하는 좁은 사례로 한정해야 합니다.

RFC 8693 요청과 응답 — 와이어 레벨 분해

Token Exchange는 토큰 엔드포인트에 대한 grant 타입 하나로 정의됩니다. grant_type 값은 urn:ietf:params:oauth:grant-type:token-exchange입니다.

요청 파라미터

파라미터필수 여부설명
grant_type필수고정값 (token-exchange URN)
subject_token필수교환 대상이 되는 원본 토큰 (보통 사용자 토큰)
subject_token_type필수subject_token의 타입 식별 URI
actor_token선택행위자(호출 서비스)를 나타내는 토큰
actor_token_typeactor_token 있으면 필수actor_token의 타입
requested_token_type선택발급받고 싶은 토큰 타입
audience선택새 토큰이 사용될 대상 서비스 (논리적 이름)
resource선택대상 리소스의 URI (RFC 8707 스타일)
scope선택새 토큰에 요청하는 스코프 (축소 권장)

토큰 타입 식별자는 URN으로 표현합니다. 자주 쓰는 값은 다음과 같습니다.

urn:ietf:params:oauth:token-type:access_token   액세스 토큰 (포맷 불문)
urn:ietf:params:oauth:token-type:refresh_token  리프레시 토큰
urn:ietf:params:oauth:token-type:id_token       OIDC ID 토큰
urn:ietf:params:oauth:token-type:jwt            JWT 자체 (포맷 명시)
urn:ietf:params:oauth:token-type:saml2          SAML 2.0 assertion

실제 HTTP 요청 예시

order-api가 사용자 토큰을 payment-api용 토큰으로 교환하는 요청입니다. 클라이언트 인증(여기서는 client_secret_basic)이 함께 들어간다는 점에 주목하십시오.

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

응답

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"
}

응답에서 RFC 8693 고유의 필드는 issued_token_type입니다. 일반 토큰 응답과 달리 발급된 토큰의 타입을 명시합니다. token_type은 프로토콜상 사용 방식(Bearer 또는 DPoP 등)을 의미하므로 둘은 다른 개념입니다.

subject_token과 actor_token

  • subject_token은 "누구에 관한 토큰인가"를 답합니다. 새 토큰의 주체가 됩니다.
  • actor_token은 "누가 행동하는가"를 답합니다. delegation 시나리오에서 호출 서비스 자신의 토큰(예: client credentials로 받은 토큰)을 넣습니다.

actor_token을 생략하면 인가 서버는 클라이언트 인증 정보 자체를 행위자로 간주할 수 있습니다. Keycloak의 표준 Token Exchange도 클라이언트 인증을 행위자 식별 수단으로 사용합니다. 명시적인 actor_token 기반 delegation이 필요한 경우 IdP의 지원 수준을 반드시 확인하십시오.

Keycloak의 Token Exchange 지원 — legacy에서 표준으로

Keycloak의 Token Exchange는 역사가 깁니다. 단계별로 정리합니다.

Legacy Token Exchange (preview, 26.1 이하)

오랫동안 Keycloak의 Token Exchange는 preview 기능이었고, 기능 플래그를 켜야 했습니다.

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

legacy 방식은 admin 권한 모델(fine-grained admin permissions)과 결합되어 있었고, internal-to-internal, internal-to-external, external-to-internal, impersonation까지 폭넓게 커버했지만 스펙 준수가 불완전하고 설정이 난해하다는 평가를 받았습니다.

표준 Token Exchange (26.2부터 정식 지원)

Keycloak 26.2부터 RFC 8693을 준수하는 표준 Token Exchange(V2)가 지원되었습니다. 핵심 특징은 다음과 같습니다.

  • internal-to-internal 교환: 같은 realm이 발급한 토큰을 다른 클라이언트/audience용으로 교환
  • 클라이언트 단위 활성화: Admin Console에서 클라이언트의 Capability config에 있는 Standard token exchange 토글로 켭니다
  • confidential 클라이언트 필수: 교환을 요청하는 클라이언트는 인증 가능해야 합니다
  • audience 파라미터로 대상 클라이언트 지정, scope 다운스코핑 지원

Admin CLI로 활성화하는 예시입니다.

# 클라이언트의 standard token exchange 활성화
kcadm.sh update clients/CLIENT-UUID -r prod \
  -s 'attributes."standard.token.exchange.enabled"="true"'

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

교환 요청 자체는 위에서 본 표준 HTTP 요청 그대로입니다. Keycloak은 audience로 지정된 클라이언트의 스코프/매퍼 설정에 따라 새 토큰의 클레임을 구성합니다.

External-to-internal과 26.6 JWT Authorization Grant

외부 IdP(예: 파트너사의 인가 서버)가 발급한 토큰을 Keycloak 토큰으로 바꾸는 external-to-internal 교환은 신뢰 연합이 필요해 더 까다롭습니다. Keycloak 26.6은 JWT Authorization Grant(RFC 7523 기반)를 도입해 이 경로를 표준적으로 풀었습니다. 외부 발급자의 JWT를 assertion으로 제출하면 Keycloak이 신뢰 설정(identity provider의 발급자/서명 키)에 따라 검증하고 자체 토큰을 발급합니다.

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

26.6의 Federated client authentication(클라이언트가 외부 발급 JWT로 자신을 인증)과 결합하면, 파트너 시스템과 비밀키 공유 없이 신뢰 체인을 구성할 수 있습니다. SPIFFE/SVID 같은 워크로드 아이덴티티와의 연계도 이 패턴 위에서 자연스럽게 풀립니다.

audience 제한과 최소 권한 설계

Token Exchange의 가치는 "교환할 때마다 권한을 깎을 수 있다"는 데 있습니다. 설계 원칙을 정리합니다.

  1. 한 토큰에 audience 하나. 교환된 토큰의 aud는 정확히 다음 홉의 서비스 하나만 가리키게 합니다.
  2. scope는 항상 다운스코핑. 사용자 토큰이 10개 스코프를 가져도, payment-api 호출에 필요한 것이 payment:read 하나라면 그것만 요청합니다. RFC 8693상 인가 서버는 요청보다 넓은 권한을 줘서는 안 됩니다.
  3. 교환 토큰의 수명은 짧게. 한 번의 다운스트림 호출에 필요한 시간(수십 초에서 수 분)이면 충분합니다. refresh token은 발급하지 않는 것이 기본입니다.
  4. 교환 가능 매트릭스를 명시적으로 관리. "order-api는 payment-api용 토큰으로만 교환 가능"처럼 클라이언트별 허용 대상을 IdP 정책으로 강제합니다.

모든 수신 서비스는 다음을 반드시 검증해야 합니다.

// Spring Security: audience 검증을 명시적으로 추가
@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;
}

audience 검증이 빠진 리소스 서버는 Token Exchange를 도입해도 보안 이득이 없습니다. 교환 인프라와 검증 강화는 반드시 한 세트입니다.

API Gateway에서의 Exchange 패턴

교환을 어디서 수행할지는 아키텍처 결정 사항입니다. 두 가지 패턴이 있습니다.

패턴 1: 게이트웨이 집중형
+------+    user token    +---------+   exchanged token   +----------+
| User | ---------------> | Gateway | ------------------> | Service  |
+------+                  |         | --+                 |    A     |
                          +---------+   |  exchanged      +----------+
                               |        +---------------> +----------+
                          (IdP와 교환)                     | Service B|
                                                          +----------+

패턴 2: 서비스 분산형
+------+   user token   +---------+  user token  +-----------+
| User | -------------> | Gateway | -----------> | Service A |
+------+                +---------+              +-----+-----+
                                                       | A가 직접 교환
                                                       v
                                                 +-----------+
                                                 | Service B |
                                                 +-----------+

게이트웨이 집중형은 교환 로직과 IdP 자격증명이 게이트웨이 한 곳에 모여 관리가 쉽고, 각 서비스는 자신용 토큰만 받습니다. 다만 게이트웨이가 모든 다운스트림 경로를 알아야 하고, 게이트웨이 침해 시 영향이 큽니다. 서비스 분산형은 각 서비스가 필요할 때 필요한 만큼만 교환하므로 최소 권한에 충실하지만, 모든 서비스가 confidential 클라이언트로 등록되고 교환 코드를 갖춰야 합니다.

실무에서는 절충형이 많습니다. 게이트웨이는 인증과 1차 audience 축소만 담당하고, 서비스 간 추가 홉은 각 서비스가 교환합니다. Spring 게이트웨이에서의 교환 호출 예시는 다음과 같습니다.

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();
}

교환 결과는 subject_token의 남은 수명 범위 내에서 캐싱하면 IdP 부하를 크게 줄일 수 있습니다. 캐시 키는 subject_token 해시 + audience + scope 조합으로 잡습니다.

Transaction Tokens — 다음 단계의 표준

홉이 많은 환경에서 매 홉마다 IdP와 왕복하는 비용이 부담된다면, IETF OAuth WG에서 진행 중인 Transaction Tokens draft를 주목할 만합니다. 핵심 아이디어는 다음과 같습니다.

  • 외부 토큰(사용자 액세스 토큰)을 신뢰 경계 진입 시점에 한 번만 Transaction Token Service에서 짧은 수명의 내부 토큰(txn token)으로 교환
  • txn token은 호출 목적(purpose), 요청 컨텍스트, 위임 체인을 담고, 내부 마이크로서비스들 사이에서만 유통
  • 교환 프로토콜 자체는 RFC 8693의 프로파일로 정의됨

즉 Token Exchange를 기반 프리미티브로 쓰되, 내부 트래픽에 최적화한 형태입니다. 2026년 현재 아직 draft이지만, 사내 표준을 설계할 때 클레임 구조(txn, purp, rctx, azd 등)를 참고하면 미래 호환성을 확보할 수 있습니다.

구현 시 보안 함정

실제 도입 과정에서 자주 밟는 지뢰들입니다.

  1. 교환 권한의 과대 부여. 모든 서비스가 모든 audience로 교환 가능하면 Token Exchange는 "토큰 세탁기"가 됩니다. 토큰을 탈취한 공격자가 원하는 audience로 바꿔가며 전 시스템을 횡단할 수 있습니다. 교환 매트릭스를 최소로 유지하고 정기 감사하십시오.
  2. subject_token 검증 부실. 인가 서버는 subject_token의 서명, 만료, 발급자, 그리고 (가능하다면) 폐기 여부까지 확인해야 합니다. 만료 직전 토큰을 긴 수명 토큰으로 바꿔주는 설정은 사실상 수명 연장 우회가 됩니다.
  3. public 클라이언트의 교환 허용. 교환 요청자는 반드시 인증되어야 합니다. SPA나 모바일 앱이 직접 교환하게 하면 행위자 인증이 무의미해집니다.
  4. act 체인 미검증. 다운스트림 서비스가 act 클레임을 검증하지 않으면 delegation의 감사 가치가 사라집니다. 민감 API는 허용된 행위자 목록을 검증 로직에 포함해야 합니다.
  5. 로그에 토큰 노출. 교환 요청 본문에는 토큰이 평문으로 들어갑니다. 게이트웨이/IdP의 액세스 로그, APM 트레이스에 form body가 기록되지 않는지 점검하십시오.
  6. refresh token 발급 남용. 교환된 토큰에 refresh token까지 따라오면 위임이 사실상 영구화됩니다. Keycloak에서는 교환 응답의 refresh token 발급 여부를 정책으로 끌 수 있습니다.

대안 패턴과의 비교

Token Exchange가 항상 정답은 아닙니다. 대안과 비교해 선택 기준을 잡아보겠습니다.

패턴사용자 컨텍스트audience 격리IdP 왕복적합한 경우
토큰 그대로 전달보존없음없음내부 PoC, 단일 신뢰 도메인의 임시 구조
client credentials소실가능있음(캐싱 가능)배치, 시스템 간 호출 등 사용자와 무관한 작업
Token Exchange보존 + 행위자 명시강함있음(캐싱 가능)사용자 대신 호출, 감사 추적 필요
Transaction Tokens보존 + 체인 보존강함진입 시 1회홉이 많은 대규모 내부 메시

판단 기준은 단순합니다. 다운스트림 작업이 "이 사용자의 권한"에 의존하는가? 그렇다면 client credentials는 부적합하고(사용자 인가를 우회하게 됨), Token Exchange가 맞습니다. 사용자와 무관한 시스템 작업이라면 client credentials가 더 단순하고 적합합니다. 토큰 그대로 전달은 마이그레이션 과도기의 임시 수단 이상으로 쓰지 마십시오.

트러블슈팅 노트

Keycloak 표준 Token Exchange 도입 시 자주 만나는 오류와 원인입니다.

오류 응답                          주요 원인
---------------------------------------------------------------
invalid_request                   subject_token_type 누락/오타,
                                  지원하지 않는 token-type URN
invalid_client                    교환 클라이언트 인증 실패,
                                  public 클라이언트로 요청
unauthorized_client               해당 클라이언트에 standard token
                                  exchange 미활성화
invalid_target (or invalid_scope) audience로 지정한 클라이언트가
                                  없거나 교환 정책상 불허
access_denied                     subject_token의 사용자에게 대상
                                  클라이언트 접근 롤이 없음

디버깅 순서는 (1) 교환 클라이언트의 Capability config 확인, (2) audience 대상 클라이언트의 존재와 scope 매핑 확인, (3) Keycloak 서버 로그의 이벤트(TOKEN_EXCHANGE, TOKEN_EXCHANGE_ERROR) 확인 순이 효율적입니다. 이벤트 로깅을 켜두면 교환 시도를 모두 추적할 수 있어 감사에도 유용합니다.

# 이벤트 설정에서 token exchange 이벤트 활성화 후 조회
kcadm.sh get events -r prod -q type=TOKEN_EXCHANGE_ERROR

마치며

Token Exchange는 "토큰을 어떻게 넘길까"라는 마이크로서비스의 오랜 숙제에 대한 표준 답안입니다. 핵심을 요약합니다.

  • 토큰 그대로 전달은 audience 격리를 무너뜨리는 안티패턴입니다. 홉마다 대상 전용 토큰으로 교환하십시오.
  • delegation(act 클레임)을 기본으로 삼아 호출 체인을 토큰에 보존하십시오. impersonation은 최소한으로.
  • Keycloak은 26.2부터 표준 교환을, 26.6부터 JWT Authorization Grant 기반의 외부 연합까지 지원합니다. legacy preview 기능에서 표준 방식으로 이관할 시점입니다.
  • 교환 매트릭스 최소화, scope 다운스코핑, 짧은 수명, audience 검증 강화가 한 세트입니다.
  • 내부 홉이 많다면 transaction tokens draft의 방향성을 미리 반영해 두십시오.

AI 에이전트가 사용자를 대신해 행동하는 시대에, "누가 누구를 대신하는가"를 암호학적으로 증명하는 능력은 선택이 아니라 기본기가 되고 있습니다. RFC 8693은 그 기본기의 표준 문법입니다.

참고 자료