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)         +-----------+
| ユーザー| -----------------------------------------> | order-api |
| (SPA)  |                                            | (サービスA)|
+--------+                                            +-----+-----+
                                                            |
                                  同じトークンをそのまま転送? |
                                                            v
                                                      +-----------+
                                                      | payment-  |
                                                      | api (B)   |
                                                      +-----------+

ユーザーのトークンは order-api のために発行されたものです。JWT の aud クレームが order-api を指しているなら、payment-api はこのトークンを拒否するのが正常です。しかしトークンをそのまま転送する構造では、悪い選択肢が二つしか残りません。

  1. payment-api が audience 検証を無効化するか緩める → どのサービス向けのトークンでも通過する万能トークンになります。トークンが 1 つ漏れればシステム全体が突破されます。
  2. 最初からすべてのサービスを audience に含めた広域トークンを発行する → 最小権限の原則に違反し、フロントエンドに露出するトークンの権限が過剰になります。

もうひとつの問題は行為主体の消失です。payment-api から見ると「このリクエストはユーザーが直接行ったのか、order-api がユーザーの代わりに行ったのか」を区別できません。監査ログに呼び出しチェーンが残らなければ、金融業界のコンプライアンス要件を満たすことは困難です。

Token Exchange はこの問題に正面から取り組みます。サービス A がユーザーのトークンを証拠(subject_token)として提示し、payment-api 専用の新しいトークンに交換してもらうのです。

+--------+   (1) access_token(aud=order-api)   +-----------+
| ユーザー| -----------------------------------> | order-api |
+--------+                                      +-----+-----+
                                                      |
                    (2) token exchange リクエスト      |
                    subject_token = ユーザートークン    v
                                              +---------------+
                                              |   Keycloak    |
                                              | (認可サーバー) |
                                              +-------+-------+
                                                      |
                    (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 アサーション

実際の 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. 1 トークンに audience 1 つ。交換されたトークンの aud は正確に次のホップのサービス 1 つだけを指すようにします。
  2. scope は常にダウンスコープ。ユーザートークンが 10 個のスコープを持っていても、payment-api 呼び出しに必要なのが payment:read 1 つならそれだけを要求します。RFC 8693 上、認可サーバーは要求より広い権限を与えてはなりません。
  3. 交換トークンの寿命は短く。1 回のダウンストリーム呼び出しに必要な時間(数十秒から数分)で十分です。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   +----------+
| ユーザー| --------------> | Gateway | ------------------> | Service  |
+--------+                 |         | --+                 |    A     |
                           +---------+   |  exchanged      +----------+
                                |        +---------------> +----------+
                           (IdP と交換)                     | Service B|
                                                           +----------+

パターン 2: サービス分散型
+--------+  user token   +---------+  user token  +-----------+
| ユーザー| ------------> | Gateway | -----------> | Service A |
+--------+               +---------+              +-----+-----+
                                                        | A が自ら交換
                                                        v
                                                  +-----------+
                                                  | Service B |
                                                  +-----------+

ゲートウェイ集中型は交換ロジックと IdP 資格情報がゲートウェイ 1 か所に集まり管理が容易で、各サービスは自分用のトークンだけを受け取ります。ただしゲートウェイがすべてのダウンストリーム経路を知る必要があり、ゲートウェイ侵害時の影響が大きくなります。サービス分散型は各サービスが必要なときに必要な分だけ交換するため最小権限に忠実ですが、すべてのサービスを 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 に注目する価値があります。核心となるアイデアは次のとおりです。

  • 外部トークン(ユーザーアクセストークン)を信頼境界への進入時点で 1 回だけ 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 はその基本スキルの標準文法です。

参考資料