Skip to content
Published on

API GatewayでのOIDCトークン検証 — Istio、Envoy、Gateway API実践

Authors

はじめに — トークン検証はどこで行うべきか

マイクロサービスアーキテクチャで最も頻繁に繰り返されるセキュリティ論争の一つが「JWTの検証はどこで行うか」です。ゲートウェイで一度だけ?すべてのサービスで?両方?2026年現在、この問いに対する業界のコンセンサスは比較的明確になりました。**エッジでふるいにかけ、サービスで再検証する(defense in depth)**です。そしてその実装手段として、Envoyベースのスタック(Istio、Envoy Gateway、Glooなど)が事実上の標準になりました。

本記事ではEnvoyのjwt_authnフィルターを基礎から理解し、IstioのRequestAuthentication + AuthorizationPolicyの組み合わせを豊富なYAML例で見ていきます。JWKSキャッシングと障害モード、audience戦略、トークン伝播パターン(RFC 8693 Token Exchangeを含む)といった運用上の難題を押さえ、Kong/APISIXのOIDCプラグイン比較、Gateway API時代の認証標準化の流れ、mTLSとJWTの組み合わせまで扱います。最後に401デバッグのフローチャートをASCIIで整理します。

エッジでの検証 vs サービスでの検証

まず両アプローチのトレードオフを整理します。

項目エッジ(ゲートウェイ)のみで検証各サービスのみで検証
パフォーマンス検証1回、内部はコストゼロホップごとに検証コストが繰り返される
一貫性中央ポリシー、設定は1か所サービスごとのライブラリ/設定の断片化
内部侵害への対応ゲートウェイを迂回されると無防備内部トラフィックも検証され堅牢
クレームの活用ヘッダーでの伝達が必要(偽造リスク管理)サービスが直接クレームへアクセス
運用負担低いライブラリのバージョン、JWKS管理が分散

結論は両者の組み合わせです。実務での推奨パターンは次のとおりです。

  1. エッジ(ゲートウェイ): 署名、発行者(iss)、有効期限(exp)、audience(aud)を検証し、不正なトラフィックを早期に遮断します。高価な内部リソースがゴミトークンに浪費されません。
  2. サービス(サイドカーまたはライブラリ): 同じ検証を繰り返しつつ、サービスごとのaudienceと細粒度の認可(スコープ、ロール)を追加します。ゲートウェイが突破されたり、内部から偽造された呼び出しが来ても防御できます。
  3. サービス間の信頼はmTLSで: ユーザートークンとは別に、呼び出し元サービスの身元はmTLS(SPIFFEなど)で証明します。後段で再び扱います。
            [エッジ: 一次検証 - 署名/iss/exp/aud]
  Client ──> API Gateway (Envoy jwt_authn) ──┐
                                             │ mTLS (サービスの身元)
                                             v
                              [サービス: 二次検証 + 細粒度の認可]
                              Service A (sidecar RequestAuthentication)
                                             │ トークンrelay or exchange
                                             v
                              Service B (sidecar + AuthorizationPolicy)

Envoy jwt_authnフィルター詳説

IstioでもEnvoy GatewayでもKongの一部モードでも、最下層でJWTを検証しているのはEnvoyのHTTPフィルターjwt_authnです。原理を知れば、上位の抽象化の動作と障害を正確に理解できます。

中核となる概念は2つです。

  • providers — 「どの発行者のトークンを、どの鍵で、どう検証するか」の定義。issuer、audiences、JWKSのソース、トークン抽出位置、ペイロードの引き渡し方法を指定します。
  • rules — 「どのルートにどのproviderを要求するか」のマッピング。requiresでproviderを指定し、allow_missingやallow_missing_or_failedといった緩和モードもあります。

設定全体の例は次のとおりです。

http_filters:
  - name: envoy.filters.http.jwt_authn
    typed_config:
      '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
      providers:
        keycloak_provider:
          issuer: https://keycloak.example.com/realms/prod
          audiences:
            - orders-api
          remote_jwks:
            http_uri:
              uri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
              cluster: keycloak_jwks_cluster
              timeout: 3s
            cache_duration: 600s
            async_fetch:
              fast_listener: false
            retry_policy:
              num_retries: 3
          # Authorization: Bearer ヘッダーから抽出(デフォルト)
          from_headers:
            - name: Authorization
              value_prefix: 'Bearer '
          # 検証済みペイロードをメタデータに格納し後続フィルター(RBACなど)が利用
          payload_in_metadata: jwt_payload
          # 検証後、アップストリームへ平文クレームヘッダーとして転送
          claim_to_headers:
            - header_name: x-jwt-sub
              claim_name: sub
            - header_name: x-jwt-scope
              claim_name: scope
          # 元のトークンをアップストリームへ残すか除去するか
          forward: true
          # exp検証のクロックスキュー許容
          clock_skew_seconds: 30
      rules:
        # ヘルスチェックはトークン不要
        - match:
            prefix: /healthz
        # 公開ドキュメントはトークンがあれば検証、なくても通過
        - match:
            prefix: /docs
          requires:
            requires_any:
              requirements:
                - provider_name: keycloak_provider
                - allow_missing: {}
        # それ以外はすべて必須
        - match:
            prefix: /
          requires:
            provider_name: keycloak_provider

設定項目ごとの注意点は次のとおりです。

  • issuerはトークンのissクレームと文字列単位で完全一致しなければなりません。末尾スラッシュ1つの違いで401になります。
  • remote_jwksのclusterは別途定義が必要です。EnvoyはJWKSエンドポイントもクラスターとして抽象化するため、DNS/TLS設定が漏れると鍵を取得できず、すべてのリクエストが401になります。
  • async_fetchを有効にすると、リスナー起動時にJWKSを事前取得し、バックグラウンドで更新します。初回リクエストの遅延とJWKSエンドポイントの瞬断の影響を軽減します。
  • forward: trueがないと、設定系統によってはAuthorizationヘッダーがアップストリームに渡る前に除去されることがあります。トークン伝播が必要なら明示してください。
  • claim_to_headersは平文ヘッダーです。アップストリームはこのヘッダーを信頼する前に、「Envoyを経由しないトラフィックが不可能であること」をネットワークレベルで保証する必要があります。

Istio — RequestAuthentication + AuthorizationPolicy

Istioは前述のjwt_authnをRequestAuthentication CRDとして、認可をAuthorizationPolicy CRDとして抽象化します。最も重要な事実を最初に強調します。

**RequestAuthentication単独では何もブロックしません。**このリソースは「トークンがあれば検証せよ」という意味に過ぎず、トークンが全くないリクエストは素通りします。ブロックするには、必ずAuthorizationPolicyで「有効な主体(requestPrincipals)を持つリクエストのみ許可」を宣言しなければなりません。この罠によるセキュリティ事故は実際に頻発しています。

ingress gatewayでの一次検証の設定です。

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: ingress-jwt
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  jwtRules:
    - issuer: https://keycloak.example.com/realms/prod
      jwksUri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
      audiences:
        - api-gateway
      forwardOriginalToken: true
      outputClaimToHeaders:
        - header: x-jwt-sub
          claim: sub
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: ingress-require-jwt
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  action: DENY
  rules:
    - from:
        - source:
            notRequestPrincipals: ['*']
      to:
        - operation:
            notPaths: ['/healthz', '/metrics']

DENY + notRequestPrincipalsのパターンは「有効なトークン主体のないリクエストを拒否する」標準イディオムです。requestPrincipalsの値はissとsubをスラッシュでつないだ形式になります。

サービス層ではより細かい認可をかけます。ordersサービスに「書き込み操作にはorders:writeスコープが必要」を表現すると次のようになります。

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: orders-jwt
  namespace: orders
spec:
  selector:
    matchLabels:
      app: orders
  jwtRules:
    - issuer: https://keycloak.example.com/realms/prod
      jwksUri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
      audiences:
        - orders-api
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: orders-authz
  namespace: orders
spec:
  selector:
    matchLabels:
      app: orders
  action: ALLOW
  rules:
    # 読み取り: 認証済み主体なら許可
    - from:
        - source:
            requestPrincipals: ['https://keycloak.example.com/realms/prod/*']
      to:
        - operation:
            methods: ['GET']
            paths: ['/orders', '/orders/*']
    # 書き込み: orders:writeスコープを要求
    - from:
        - source:
            requestPrincipals: ['https://keycloak.example.com/realms/prod/*']
      to:
        - operation:
            methods: ['POST', 'PUT', 'DELETE']
            paths: ['/orders', '/orders/*']
      when:
        - key: request.auth.claims[scope]
          values: ['*orders:write*']

ALLOWポリシーが1つでも存在するワークロードは「マッチしないすべてのリクエストを拒否」する動作に変わる点も覚えておくべきです。ポリシーを追加した瞬間、デフォルトがdeny-by-defaultに切り替わります。

JWKSキャッシングと障害モード

JWT検証システムの可用性はJWKSエンドポイントの管理で決まります。障害シナリオを表で整理します。

シナリオ症状対応
JWKSエンドポイントの瞬断キャッシュ失効後のfetch失敗 → 全面401async_fetch + 余裕あるキャッシュTTL、IdPの冗長化
鍵ローテーション直後新しいkidのトークンがキャッシュミスで401IdPは新鍵公開後、猶予期間を置いてから署名を切り替える
IdPが旧鍵を即時削除既存トークンがすべて401鍵のretireは最大トークン寿命以上遅延させる
ゲートウェイ再起動 + IdPダウンJWKSの初回fetch失敗fail-openの可否をポリシーで決定、ローカルファイルへのフォールバック
クロックスキュー断続的なexp/nbf検証失敗clock_skew_seconds設定 + NTP監視

運用上の推奨事項は次のとおりです。

  • キャッシュTTLはIdPの鍵ローテーション周期と一体で設計します。例: ローテーション24時間前に新鍵を公開、キャッシュは10分 — これならキャッシュが古くても検証は壊れません。
  • Envoyのlocal_jwks(ファイルベース)を非常用に準備しておけば、IdPの完全障害時でも既存の鍵で検証を継続できます。ただし失効済みの鍵が生き残るリスクとのトレードオフです。
  • JWKS fetch失敗率、キャッシュヒット率、401比率をメトリクスとして公開しアラートを設定します。Envoyはjwt_authnの統計(denied、jwks_fetch_failedなど)を提供します。

Audience戦略

audクレームは「このトークンが誰のためのものか」を宣言します。戦略の選択肢は3つです。

  1. 単一audience(ゲートウェイ用に1つ) — 実装は単純ですが、トークンがどのサービスでも再利用可能になり、トークン窃取時の影響範囲が大きくなります。
  2. サービスごとのaudience — 各サービスが自分のaudienceのみ受け入れます。最も安全ですが、クライアントはサービスごとに異なるトークンを取得する必要があり、サービス間呼び出しにはtoken exchangeが必要になります。
  3. 階層型(現実的な折衷) — 外部公開API単位でaudienceを分け、内部の細分化はスコープで処理します。ゲートウェイは広域のaudienceを、各サービスは自分が属するAPIのaudience + スコープを検証します。

推奨原則は次のとおりです。

  • 少なくとも「この組織のトークンなら全部通す」というaudience未検証の状態は避けます。RFC 9700(OAuth Security BCP)もaudience restrictionを主要な緩和策として明記しています。
  • access tokenのaudienceはリソースサーバー基準とし、ID token(aud=クライアント)はAPI呼び出しに使いません。ID tokenをAPIに送るのはよくあるアンチパターンです。

トークン伝播 — 元のトークンの転送 vs Token Exchange

サービスAがユーザーリクエストを受けてサービスBを呼び出すとき、ユーザーコンテキストをどう引き継ぐかという問題です。

パターン1: 元のトークンをそのまま転送(token relay)

Client --(JWT aud=api)--> Gateway --(同じJWT)--> Service A --(同じJWT)--> Service B
  • 長所: シンプル、追加のIdP往復なし。
  • 短所: audienceを広域にせざるを得ず、トークン窃取時にすべてのサービスが危険にさらされる。トークンの寿命の間、BがAになりすまして他のサービスを呼び出せる。委譲情報がなく監査が不完全。

パターン2: Token Exchange (RFC 8693)

サービスAが受け取ったトークンをIdPに提示し、audienceがBに絞られた新しいトークンと交換します。

curl -s -X POST https://keycloak.example.com/realms/prod/protocol/openid-connect/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d client_id=service-a \
  -d client_secret=SERVICE_A_SECRET \
  -d subject_token=ORIGINAL_USER_ACCESS_TOKEN \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=service-b

応答で受け取るトークンにはsub(元のユーザー)とともにact(actor)クレームが入り、「service-aがユーザーの代わりに行動している」という委譲チェーンが記録されます。

{
  "iss": "https://keycloak.example.com/realms/prod",
  "sub": "user-1234",
  "aud": "service-b",
  "scope": "orders:read",
  "act": {
    "sub": "service-account-service-a"
  },
  "exp": 1781234567
}
  • 長所: audienceの最小化、委譲チェーンの保存、権限の縮小(scope down)が可能。AIエージェント時代の委譲追跡にも同じメカニズムが使われます。
  • 短所: ホップごとのIdP往復(キャッシング必須)、IdP側のexchangeポリシー管理の負担。

実務上の折衷案は「信頼境界を越える箇所(ドメイン間、機密サービスへの進入)でのみexchangeし、同じ信頼境界内ではrelay + mTLS」です。なお、この流れを標準化するtransaction tokensの議論がOAuth WGで進行中であり、次回(SPIFFE/SPIRE)でワークロードの身元とともに再び扱います。

Kong vs APISIX — OIDCプラグイン観点の比較

Envoy系以外で最もよく使われる2つのゲートウェイのOIDC処理方式を比較します。

項目Kong (openid-connectプラグイン)APISIX (openid-connect / authz-keycloak)
基盤nginx/OpenResty + lua-resty-openidcnginx/OpenResty + lua-resty-openidc
ライセンス範囲OIDCプラグインはEnterpriseOSSに含まれる
動作モード検証(JWT)、セッション(Cookie)、relying party検証、relying party、Keycloak認可連携
JWKSキャッシング内蔵、ディスカバリーキャッシュ内蔵、ディスカバリーキャッシュ
細粒度の認可ACL/スコープのプラグイン組み合わせauthz-keycloakでUMA権限評価を委譲
宣言的管理decK、Kong CRDAPISIX CRD、ADC

APISIXの設定例は次のとおりです。

apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: orders-route
  namespace: apps
spec:
  http:
    - name: orders
      match:
        hosts:
          - api.example.com
        paths:
          - /orders/*
      backends:
        - serviceName: orders
          servicePort: 8080
      plugins:
        - name: openid-connect
          enable: true
          config:
            discovery: https://keycloak.example.com/realms/prod/.well-known/openid-configuration
            client_id: apisix-gateway
            client_secret: GATEWAY_CLIENT_SECRET
            bearer_only: true
            use_jwks: true
            token_signing_alg_values_expected: RS256
            audience: orders-api

bearer_only: trueは「ブラウザリダイレクトなしにBearerトークンのみ検証する」APIゲートウェイモードです。Webアプリのセッションまでゲートウェイに処理させたい場合はbearer_onlyを無効にしてrelying partyモードで使います(この場合、ゲートウェイはIAPに近づきます)。

Gateway API時代の認証標準化

KubernetesのGateway APIはIngressの後継としてルーティングの表現を標準化しましたが、認証/認可は長らく実装ごとの拡張(ポリシーCRD)の領域でした。2025〜2026年の流れは次のとおりです。

  • Gateway API 1.4でBackendTLSPolicyなどのポリシー添付(Policy Attachment)パターンが定着し、認証フィルターの標準化の議論(HTTPRouteレベルのJWT/extAuthフィルター)がGEP(Gateway Enhancement Proposal)として進行中です。
  • それまでの実務は実装ごとのポリシーCRDを使います。Envoy GatewayのSecurityPolicyが代表例です。
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: orders-jwt
  namespace: apps
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: orders-route
  jwt:
    providers:
      - name: keycloak
        issuer: https://keycloak.example.com/realms/prod
        audiences:
          - orders-api
        remoteJWKS:
          uri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
        claimToHeaders:
          - claim: sub
            header: x-jwt-sub

同じEnvoyベースでも、Istioは独自CRD、Envoy GatewayはSecurityPolicy、Glooはまた別のCRDを使うという断片化が現在の現実です。ただしすべて下層はjwt_authnフィルターなので、本記事前半の原理理解はすべての実装に通用します。長期的にはHTTPRouteに認証フィルターを標準文法で付与する方向が有力です。

mTLSとJWTの組み合わせ — サービスの身元とユーザーの身元

mTLSとJWTは競合関係ではなく、異なる問いに答える直交的な手段です。

  • mTLS (peer identity) — 「このリクエストを送ったワークロードは誰か」。IstioではPeerAuthenticationで強制し、身元はSPIFFE形式のprincipalで表現されます。
  • JWT (request identity) — 「このリクエストが代弁する最終ユーザーは誰か」。

両者を組み合わせたAuthorizationPolicyがZero Trustマイクロサービスの標準形です。

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: orders
spec:
  mtls:
    mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: orders-payment-call
  namespace: payments
spec:
  selector:
    matchLabels:
      app: payments
  action: ALLOW
  rules:
    - from:
        - source:
            # 呼び出し元ワークロードを制限(mTLSベースのサービス身元)
            principals: ['cluster.local/ns/orders/sa/orders-sa']
            # 最終ユーザーのトークンも併せて要求
            requestPrincipals: ['https://keycloak.example.com/realms/prod/*']
      to:
        - operation:
            methods: ['POST']
            paths: ['/payments']
      when:
        - key: request.auth.claims[scope]
          values: ['*payments:write*']

このポリシーは「ordersサービスアカウントのワークロードが、有効なユーザートークンとpayments:writeスコープを持って、POST /paymentsを呼び出すときのみ許可」を1枚で表現しています。サービスの身元(mTLS)とユーザーの身元(JWT)の二重検証です。

パフォーマンスの観点

JWT検証コストに関する一般的な観察を整理します(絶対値は環境依存のため、自前でのベンチマークを推奨します)。

  • RS256の署名検証はリクエストあたり数十マイクロ秒のレベルで、p50レイテンシーにはほぼ影響しません。ES256/EdDSAは検証がより軽く鍵も短いため、2026年の新規構築では好まれます(Keycloak 26.6はEdDSAをサポートします)。
  • 本当のコストは署名演算ではなく、JWKS fetchがリクエスト経路に入り込む瞬間です。async_fetchとキャッシュでリクエスト経路から切り離すことが核心です。
  • サイドカーでの二次検証による追加遅延は一般にホップあたり1ms未満で、defense in depthの価値に対して受容可能な水準です。
  • トークンサイズは見落とされがちなコストです。グループ/権限をすべてクレームに詰め込んで8KBを超えると、ヘッダー上限超過で4xxになる事故がよくあります。クレームは識別子中心に薄く保ち、細粒度の権限はOpenFGAのような専用認可サービスに委譲するのがトレンドです。

トラブルシューティング — 401デバッグのフローチャート

ゲートウェイ401の原因を体系的に絞り込む手順です。

                         +--------------------------+
                         | 401が発生                 |
                         +-----------+--------------+
                                     |
                  トークンはリクエストに載っているか?(ヘッダー確認)
                                     |
              +----------- いいえ ---+--- はい ----------+
              |                                          |
   クライアント/プロキシがAuthorization      トークンをデコードして観察(検証ではない)
   ヘッダーを欠落/除去?(プロキシチェーン点検)            |
                                     +------------------+------------------+
                                     |                  |                  |
                               issは設定と         expは過ぎているか?  audは設定と
                               完全一致?           (クロックスキュー含む) 一致するか?
                                     |                  |                  |
                              不一致: trailing      期限切れ: 更新     不一致: audience
                              slash、http/https、   ロジック点検、     マッピングの再設計
                              realmパスを確認       NTP確認
                                     |
                         すべて正常なら → 鍵検証の段階を疑う
                                     |
                  +------------------+-------------------+
                  |                                      |
        トークンヘッダーのkidはJWKSに          ゲートウェイはJWKSを
        存在するか?(curlでJWKS確認)            取得できているか?
                  |                                      |
        ない: 鍵ローテーション直後の          fetch失敗: クラスター定義、
        キャッシュ問題 → キャッシュTTL/       DNS、egressポリシー、TLS
        ローテーション猶予ポリシー点検        信頼チェーンを点検
                  |
        すべて正常なのに401 → RequestAuthenticationは通過し
        AuthorizationPolicyで拒否(403の可能性も)されていないか、
        ルールマッチング(パス/メソッド/スコープ)を点検

併せて使う診断コマンド集です。

# 1) トークンのペイロード確認(署名検証なしのデコード)
TOKEN=eyJhbGciOi...
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# 2) JWKSを直接照会 — kid一覧を確認
curl -s https://keycloak.example.com/realms/prod/protocol/openid-connect/certs | jq '.keys[].kid'

# 3) Istioの設定がEnvoyに反映されたか確認
istioctl proxy-config listener deploy/istio-ingressgateway -n istio-system -o json \
  | jq '.. | select(.name? == "envoy.filters.http.jwt_authn")'

# 4) Envoyのアクセスログで拒否理由フラグを確認
kubectl logs deploy/istio-ingressgateway -n istio-system | grep -E '401|403' | tail -5

# 5) jwt_authn統計でどの段階で弾かれているか確認
kubectl exec deploy/istio-ingressgateway -n istio-system -- \
  pilot-agent request GET stats | grep -E 'jwt_authn|jwks'

401と403の区別も重要です。Istioでは401はRequestAuthentication(トークン自体の問題)、403はAuthorizationPolicy(トークンは有効だが権限不足)で発生します。デバッグの最初の分岐点にしてください。

おわりに

API Gateway層のOIDCトークン検証は、「エッジでふるいにかけ、サービスで再検証する」という原則の上に、Envoy jwt_authnという共通基盤へ収斂しました。まとめると次のとおりです。

  • RequestAuthenticationは何もブロックしません。AuthorizationPolicyまでが1セットです。
  • 可用性はJWKSキャッシングの設計で決まります。async fetch、TTL、鍵ローテーションの猶予を一体で設計してください。
  • audienceは狭く、トークンは薄く、信頼境界を越えるときはtoken exchangeを検討してください。
  • mTLS(サービスの身元)とJWT(ユーザーの身元)は組み合わせてこそZero Trustが完成します。

次回は本記事のmTLS側の半分、すなわちSPIFFE/SPIREベースのワークロードアイデンティティを深く扱います。

参考資料