Skip to content
Published on

OAuth 2.1 マイグレーションガイド — PKCE 義務化時代の認証設計

Authors

はじめに

OAuth 2.0(RFC 6749)が発行されてから 14 年が経ちました。その間、OAuth 2.0 は事実上すべての API 認可の標準となりましたが、同時に数多くのセキュリティインシデントの原因にもなりました。Implicit フローでアクセストークンが URL フラグメントに露出し、認可コードが横取り攻撃(authorization code interception)で奪われ、redirect URI の緩いマッチングによってトークンが攻撃者のサーバーへ流れる事故が繰り返されてきました。

これらの問題を解決するため、IETF の OAuth ワーキンググループはセキュリティ勧告(Security BCP)を更新し続け、2025 年 1 月にその決定版である RFC 9700 が発行されました。そして、これらすべての教訓を単一の仕様に統合したものが OAuth 2.1 draft です。

2026 年現在、OAuth 2.1 はまだ IETF draft の段階ですが、業界ではすでに事実上の標準です。特に AI エージェント時代が本格化したことで、その地位はさらに強固になりました。MCP(Model Context Protocol)の authorization 仕様は OAuth 2.1 をベースに書かれており、Keycloak 26.6 は OAuth Client ID Metadata Document(CIMD)を実験的にサポートし、MCP authorization server の役割まで担えるようになりました。新しく作るシステムであれば、OAuth 2.0 のレガシーパターンを踏襲する理由はまったくありません。

本記事では、OAuth 2.0 の問題点、OAuth 2.1 が統合した変更点、PKCE の動作原理、そして既存アプリケーションを OAuth 2.1 互換へマイグレーションする具体的なチェックリストを扱います。

OAuth 2.0 の問題点 — なぜ 2.1 が必要だったのか

仕様が分散していた

OAuth 2.0 を「正しく」実装するためには、読むべき文書が多すぎました。

文書内容発行
RFC 6749OAuth 2.0 コアフレームワーク2012
RFC 6750Bearer Token の使い方2012
RFC 7636PKCE2015
RFC 8252ネイティブアプリ BCP2017
RFC 9700Security BCP(旧 draft-ietf-oauth-security-topics)2025

問題は、RFC 6749 だけを読んで実装されたシステムがあまりにも多かったことです。2012 年当時は合法だった Implicit フロー、password grant、ワイルドカード redirect URI が、そのままプロダクションに残りました。OAuth 2.1 はこの 5 つの文書のうち中核となる 3 つ — RFC 6749 + RFC 7636(PKCE)+ RFC 9700(Security BCP)— を 1 つに統合し、危険なオプションを仕様レベルで削除しました。

具体的な攻撃シナリオ

OAuth 2.0 時代に実際に繰り返された攻撃を整理すると次のとおりです。

攻撃 1: Implicit フローのトークン漏洩
  - access_token が URL フラグメント(#access_token=...)で渡される
  - ブラウザ履歴、Referer ヘッダー、プロキシログにトークンが残る
  - 悪意あるスクリプトが location.hash を読んでトークンを窃取

攻撃 2: Authorization Code Interception(モバイル)
  - カスタム URL スキーム(myapp://callback)を悪意あるアプリが
    同一に登録
  - OS が悪意あるアプリに認可コードを渡す
  - 攻撃者がコードをトークンに交換(PKCE がなければ防げない)

攻撃 3: Redirect URI 部分一致の悪用
  - 登録: https://app.example.com/*
  - 攻撃: https://app.example.com/redirect?url=https://evil.com
  - オープンリダイレクタを経由してコード/トークンが攻撃者に渡る

攻撃 4: CSRF によるアカウント連携の改ざん
  - state パラメータ未使用の場合、攻撃者の認可レスポンスを
    被害者のセッションに注入できる

攻撃 5: 窃取されたリフレッシュトークンの無期限使用
  - rotation がなければ、窃取されたリフレッシュトークンは
    有効期限まで(あるいは無期限に)有効

このうち 1 と 2 はフロー自体の設計上の欠陥であり、3〜5 は仕様が「推奨」にとどまっていた部分です。OAuth 2.1 はこれらすべてを義務(MUST)に格上げするか、機能自体を削除しました。

OAuth 2.1 の主要な変更点

OAuth 2.1 draft の変更点を 1 つの表にまとめると次のとおりです。

項目OAuth 2.0OAuth 2.1
PKCE任意(RFC 7636、主にモバイル)すべての authorization code フローで義務
Implicit grant許可削除
Resource Owner Password Credentials許可削除
Redirect URI マッチング実装依存(部分一致が一般的)完全な文字列比較(exact matching)が義務
リフレッシュトークン(public client)制約なしsender-constrained または rotation が義務
クエリ文字列での Bearer token許可禁止
state パラメータ推奨CSRF 防御は PKCE が吸収、state はアプリ状態用

ひとつずつ詳しく見ていきましょう。

Implicit grant の削除 — なぜ、そして何に置き換えるか

Implicit フローは、2012 年当時のブラウザの制約(CORS 未対応)のために作られた妥協案です。トークンエンドポイントを呼ばずに、認可レスポンスから直接 access token をフラグメントで受け取る構造でした。

[Implicit フロー - 削除済み]
Browser ──> /authorize?response_type=token ──> IdP
Browser <── 302 Location: https://app/#access_token=eyJ... <── IdP
            └─ トークンが URL にそのまま露出

問題点は明確です。

  1. トークンが URL に露出し、履歴/ログ/Referer 経由で漏洩します。
  2. トークンエンドポイントを経由しないため、クライアント認証が不可能です。
  3. レスポンスがクライアントに直接届くため、トークンのローテーションや sender-constraining を適用できません。

置き換えパターンはシンプルです。今やすべてのブラウザが CORS をサポートしているため、SPA も authorization code フロー + PKCE を使えばよいのです。トークンは token エンドポイントから POST レスポンスボディで渡され、URL には使い捨ての認可コードが一瞬現れるだけです。

ROPC(password grant)の削除 — なぜ、そして何に置き換えるか

Resource Owner Password Credentials grant は、ユーザーの ID とパスワードをクライアントアプリが直接受け取り、トークンエンドポイントに送信する方式です。

POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=alice&password=hunter2&client_id=legacy-app

この方式は OAuth の存在意義そのものを否定します。OAuth は「パスワードをサードパーティアプリに渡さないため」に作られたプロトコルなのに、ROPC は正反対のことをします。さらに、次のすべてを不可能にします。

  • MFA や passkeys などの強化された認証手段の適用
  • IdP 主導のリスクベース認証(risk-based authentication)
  • フィッシング耐性 — アプリが偽物ならパスワードがそのまま奪われます
  • SSO — パスワードが IdP ではなく個別アプリを経由します

置き換えパターンは用途によって異なります。

ROPC を使っていた理由置き換えパターン
自社モバイルアプリのネイティブログイン UIAuthorization code + PKCE(システムブラウザ/Custom Tab)
サーバー間通信、バッチ処理client_credentials grant
CLI ツールのログインDevice authorization grant(RFC 8628)または PKCE + ループバックリダイレクト
テスト自動化テスト専用 client_credentials またはトークン事前発行

Redirect URI の完全一致(exact matching)

OAuth 2.1 は redirect URI を**単純な文字列比較(simple string comparison)**で検証することを要求します。ワイルドカード、部分一致、正規表現マッチングはすべて禁止です。

登録された URI:  https://app.example.com/callback

リクエストされた URI                                 判定
https://app.example.com/callback                    許可
https://app.example.com/callback/                   拒否(末尾スラッシュ)
https://app.example.com/callback?next=/home         拒否(クエリ追加)
https://app.example.com.evil.com/callback           拒否
http://app.example.com/callback                     拒否(scheme が異なる)

Keycloak でよく見かけるアンチパターンが、Valid Redirect URIs にワイルドカードを入れることです。

悪い設定:  https://app.example.com/*        ← オープンリダイレクタと組み合わさるとトークン漏洩
悪い設定:  *                                 ← 絶対禁止
良い設定:  https://app.example.com/auth/callback   (必要なパスをすべて明示)

唯一の例外はネイティブアプリのループバックリダイレクト(127.0.0.1)で、ポート番号のみ可変が許されます(RFC 8252)。

PKCE の動作原理 詳細

PKCE(Proof Key for Code Exchange、RFC 7636)は OAuth 2.1 の心臓部です。「ピクシー」と読みます。

コアアイデア

認可コードを要求した主体と、それをトークンに交換する主体が同一であることを証明するメカニズムです。クライアントは認可リクエストごとに使い捨ての秘密値(code_verifier)を生成し、そのハッシュ(code_challenge)だけを先に送ります。コード交換時に元の値を提出すると、サーバーがハッシュを再計算して照合します。

1) クライアント: code_verifier を生成
   - 43〜128 文字のランダム文字列(A-Z, a-z, 0-9, -._~)
   - 最低 256 ビットのエントロピーを持つ CSPRNG を使用

2) クライアント: code_challenge を計算
   code_challenge = BASE64URL( SHA256( code_verifier ) )

3) 認可リクエスト(フロントチャネル)
   GET /authorize?response_type=code
       &client_id=spa-client
       &redirect_uri=https://app.example.com/callback
       &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
       &code_challenge_method=S256
       &scope=openid profile
       &state=af0ifjsldkj

4) IdP: code_challenge を認可コードとともに保存しコードを発行

5) トークンリクエスト(バックチャネル)
   POST /token
   grant_type=authorization_code
   &code=SplxlOBeZQQYbYS6WxSbIA
   &redirect_uri=https://app.example.com/callback
   &client_id=spa-client
   &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

6) IdP: BASE64URL(SHA256(受信した code_verifier)) == 保存済み code_challenge ?
   - 一致: トークン発行
   - 不一致: invalid_grant エラー

攻撃者が認可コードを横取りしても、code_verifier を知らないためトークン交換に失敗します。code_verifier はフロントチャネル(ブラウザリダイレクト)には決して露出せず、TLS で保護されたバックチャネルでのみ送信されます。

code_challenge_method: S256 vs plain

method計算式備考
S256BASE64URL(SHA256(verifier))サポート義務、常にこれを使用
plainverifier そのまま認可リクエストが漏れると無力化、使用禁止

OAuth 2.1 では plain は「S256 が技術的に使えない場合」のみ許可されますが、現実的にそのような環境は存在しません。

ダウングレード攻撃と防御

PKCE 自体にもダウングレード攻撃ベクターがあります。RFC 9700 が詳しく扱っている部分です。

シナリオ: PKCE ダウングレード
  1. 攻撃者が被害者の認可リクエストから code_challenge を削除、
     あるいは plain に差し替えようとする
  2. サーバーが「PKCE は任意」として動作すると、challenge のない
     リクエストも受理してしまう
  3. 攻撃者が横取りしたコードを verifier なしで交換に成功

防御(サーバー側):
  - 認可リクエストに code_challenge があった場合、トークン
    リクエストに code_verifier が必須(なければ拒否)
  - 認可リクエストに code_challenge がなかったのにトークン
    リクエストに code_verifier が来たら拒否
  - クライアントポリシーで PKCE を強制(Keycloak:
    Advanced Settings > PKCE Code Challenge Method = S256)

Keycloak ではクライアント単位で PKCE を強制できます。

# kcadm で PKCE S256 を強制設定
kcadm.sh update clients/CLIENT-UUID -r myrealm \
  -s 'attributes."pkce.code.challenge.method"=S256'

コード例 — verifier/challenge の生成

// SPA (ブラウザ) - Web Crypto API
function base64url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function createPkcePair() {
  const random = crypto.getRandomValues(new Uint8Array(32));
  const verifier = base64url(random.buffer); // 43 chars
  const digest = await crypto.subtle.digest(
    'SHA-256', new TextEncoder().encode(verifier)
  );
  const challenge = base64url(digest);
  return { verifier, challenge };
}
// Java - Spring Security を使わず直接実装する場合
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

public final class Pkce {
    private static final SecureRandom RANDOM = new SecureRandom();
    private static final Base64.Encoder B64 =
        Base64.getUrlEncoder().withoutPadding();

    public static String newVerifier() {
        byte[] bytes = new byte[32];
        RANDOM.nextBytes(bytes);
        return B64.encodeToString(bytes);
    }

    public static String challengeOf(String verifier) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] digest = md.digest(verifier.getBytes("US-ASCII"));
        return B64.encodeToString(digest);
    }
}

Spring Security を使うなら自前実装は不要で、設定一行で完了します。

# application.yml - Spring Security 6.x は public client に PKCE を自動適用
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-spa
            authorization-grant-type: authorization_code
            redirect-uri: "http://localhost:8080/login/oauth2/code/keycloak"
            scope: openid,profile
            client-authentication-method: none   # public client → PKCE 自動
        provider:
          keycloak:
            issuer-uri: https://idp.example.com/realms/myrealm

リフレッシュトークン処理の強化

OAuth 2.1 は public client(SPA、モバイル)のリフレッシュトークンに対し、次のいずれかを要求します。

  1. Sender-constrained: DPoP(RFC 9449)や mTLS(RFC 8705)でトークンを特定のクライアント鍵にバインド
  2. Rotation: リフレッシュトークンを使用するたびに新しいトークンを発行し、以前のトークンを無効化

rotation の核心は**再利用検知(reuse detection)**です。

正常フロー:
  RT1 使用 → AT2 + RT2 発行、RT1 無効化
  RT2 使用 → AT3 + RT3 発行、RT2 無効化

窃取シナリオ:
  攻撃者が RT1 を窃取 → 正規ユーザーが RT1 を使用 → RT2 発行
  攻撃者が RT1 を再利用しようとする
  → サーバー: 「RT1 はすでに使用済み」= 窃取のシグナル
  → トークンファミリー(RT1 から派生したすべてのトークン)全体を無効化
  → ユーザーに再ログインを促す

Keycloak の設定は realm 単位で行います。

# Realm Settings > Sessions / Tokens
kcadm.sh update realms/myrealm \
  -s revokeRefreshToken=true \
  -s refreshTokenMaxReuse=0

リフレッシュトークンのライフサイクル設計は、別記事(リフレッシュトークンローテーションとセッション管理)でさらに深く扱います。

クライアントタイプ別の推奨パターン

SPA(ブラウザ単独)

推奨: Authorization Code + PKCE (public client)
      または BFF パターン (confidential client、トークンをブラウザに置かない)

┌─────────┐  1. /authorize (+ code_challenge)   ┌─────────┐
│ Browser │ ──────────────────────────────────> │   IdP   │
│  (SPA)  │ <── 2. 302 + code ───────────────── │         │
│         │ ── 3. /token (+ code_verifier) ───> │         │
│         │ <── 4. AT + RT (rotation 適用) ──── │         │
└─────────┘                                     └─────────┘
  • トークンの保存はメモリ(JS 変数)が基本です。localStorage は XSS に脆弱です。
  • より高いセキュリティが必要なら、BFF(Backend-for-Frontend)でトークンをサーバー側に置き、ブラウザには HttpOnly セッションクッキーだけを発行します。

モバイル/デスクトップ ネイティブアプリ

推奨: Authorization Code + PKCE + システムブラウザ (RFC 8252)
  - iOS: ASWebAuthenticationSession
  - Android: Custom Tabs
  - redirect: HTTPS ベースの App Links / Universal Links を推奨
              (カスタムスキームより横取り耐性が高い)
  - WebView 内蔵ログインは禁止(フィッシング + クッキー分離の問題)

サーバーサイド Web アプリ

推奨: Authorization Code + PKCE (confidential client)
  - client_secret または private_key_jwt でクライアント認証
  - PKCE は confidential client にも適用 (OAuth 2.1 で義務)
    → authorization code injection を防御
  - セッションクッキー: HttpOnly + Secure + SameSite=Lax 以上

confidential client なのになぜ PKCE が必要なのか疑問に思うかもしれません。client_secret は「トークンリクエストを送ったのがそのクライアントである」ことを証明しますが、「その認可コードがそのセッションで開始されたリクエストの結果である」ことは証明できません。攻撃者が自分の認可コードを被害者のセッションに注入する code injection 攻撃は、PKCE(または OIDC の nonce)でしか防げません。

サーバー間通信(ユーザーなし)

推奨: client_credentials grant
  - 認証: private_key_jwt または mTLS > client_secret
  - 2026 トレンド: ワークロードアイデンティティ(SPIFFE/SVID)と連携、
    Keycloak 26.6 の Federated client authentication で
    Kubernetes サービスアカウントトークンをクライアント認証に使用可能

既存アプリのマイグレーションチェックリスト

実務でそのまま使えるチェックリストです。順番に進めてください。

ステップ 1 — 現状調査

  • すべての OAuth クライアントをリスト化(IdP 管理コンソールからエクスポート)
  • grant type 別に分類: implicit / password / authorization_code / client_credentials
  • redirect URI にワイルドカードや http(非 TLS)がないか点検
  • PKCE 未適用の authorization_code クライアントを特定
  • リフレッシュトークンポリシー(rotation の有無、有効期間)を確認

Keycloak であれば次のように調査できます。

# implicit フローが有効なクライアントを探す
kcadm.sh get clients -r myrealm --fields clientId,implicitFlowEnabled \
  | jq '.[] | select(.implicitFlowEnabled == true) | .clientId'

# Direct Access Grants(ROPC)が有効なクライアントを探す
kcadm.sh get clients -r myrealm --fields clientId,directAccessGrantsEnabled \
  | jq '.[] | select(.directAccessGrantsEnabled == true) | .clientId'

ステップ 2 — リスクの高いものから削除

  • Implicit フローのクライアントを code + PKCE に転換(ライブラリ交換レベル)
  • ROPC クライアントを用途に応じて code+PKCE / client_credentials / device flow に転換
  • ワイルドカード redirect URI を明示的なリストに置き換え
  • クエリ文字列で access token を渡す API 呼び出しを排除(Authorization ヘッダーへ)

ステップ 3 — PKCE と rotation の適用

  • すべての authorization_code クライアントに PKCE S256 を強制(IdP ポリシー)
  • public client にリフレッシュトークン rotation + reuse detection を有効化
  • クライアントライブラリを標準ライブラリに統一(AppAuth、oidc-client-ts、Spring Security など)

ステップ 4 — 検証

  • challenge のない認可リクエストが拒否されることをテスト
  • 誤った verifier でのトークン交換が失敗することをテスト
  • redirect URI の変形(末尾スラッシュ、クエリ追加)が拒否されることをテスト
  • リフレッシュトークン再利用時にファミリー無効化が動作することをテスト
# PKCE 強制の確認: challenge なしで認可リクエスト → エラーになれば正常
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://idp.example.com/realms/myrealm/protocol/openid-connect/auth?client_id=my-spa&response_type=code&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback"

よくある落とし穴

落とし穴症状解決策
verifier を localStorage に保存タブ間競合、XSS 露出sessionStorage またはメモリ
コードの再利用invalid_grant が断続的に発生コールバックハンドラの冪等化、React StrictMode の二重実行に注意
時計のずれトークンが即座に期限切れ扱いNTP 同期、clock skew 許容値の設定
プロキシがクエリをログに記録認可コードがログに残るコード有効期間 60 秒以下 + 一回限り使用の保証を確認
ROPC 削除後にテストが壊れるCI の認証失敗テスト用 client_credentials クライアントを分離

AI エージェント時代と OAuth 2.1 — MCP の文脈

2025〜2026 年に OAuth 2.1 が一気に「事実上の標準」となった決定的な契機は AI エージェントです。

MCP(Model Context Protocol)は LLM エージェントが外部ツール/データサーバーにアクセスするための標準プロトコルですが、その authorization 仕様は OAuth 2.1 ベースで書かれています。MCP サーバーは OAuth 2.1 の resource server として、MCP クライアント(エージェントホスト)は OAuth 2.1 のクライアントとして動作します。PKCE は当然義務であり、dynamic client registration(RFC 7591)と protected resource metadata(RFC 9728)が積極的に活用されます。

ここで興味深いのが Keycloak 26.6 の OAuth Client ID Metadata Document(CIMD)実験的サポートです。エージェントのように事前登録が難しいクライアントが、自身のメタデータ文書の URL 自体を client_id として使う方式で、Keycloak が MCP authorization server の役割を果たせるようになります。人間ではない主体(non-human identity)が爆発的に増える環境では、「すべてのクライアントを管理者が手動登録する」という前提が崩れつつあるというシグナルです。

エージェントがユーザーの代わりに行動するシナリオでは、Token Exchange(RFC 8693)と組み合わせた委任チェーンの設計も重要になります。Keycloak 26.6 の JWT Authorization Grant サポートはまさにこうしたシナリオを狙ったものです。要するに OAuth 2.1 は、「人間のログイン」を超えて「エージェントの委任された行動」までカバーする基盤レイヤーになったのです。

運用ベストプラクティス

  1. IdP ポリシーで強制しましょう。 クライアント開発者の善意に頼らず、IdP 側で PKCE S256 の義務化、implicit/ROPC の無効化を realm のデフォルトに設定します。Keycloak 26.6 は FAPI 2.0 Security Profile を Final レベルでサポートしているため、高セキュリティ環境なら FAPI 2.0 クライアントポリシーをそのまま適用するのも良い選択です。
  2. トークンの有効期間は短く。 access token は 5〜15 分、リフレッシュトークンは rotation とともに idle 30 日 / max 90 日程度から始めて調整します。
  3. 標準ライブラリだけを使いましょう。 OAuth フローの自前実装はアンチパターンです。oidc-client-ts(SPA)、AppAuth(モバイル)、Spring Security OAuth2 Client(Java)、golang.org/x/oauth2(Go)のような実績あるライブラリを使います。
  4. モニタリング指標を定めましょう。 invalid_grant の比率、PKCE 検証失敗数、リフレッシュトークン再利用検知数は攻撃の早期シグナルです。
  5. 段階的なマイグレーション。 新規クライアントから OAuth 2.1 ポリシーを適用し、レガシーには deprecation スケジュールを告知したうえで段階的にブロックします。

おわりに

OAuth 2.1 は新機能を追加した仕様ではなく、14 年間のセキュリティインシデントから得た教訓をデフォルトにした仕様です。まとめると次のとおりです。

  • PKCE はすべての場所に: public でも confidential でも、authorization code フローには常に S256 PKCE を適用します。
  • Implicit と ROPC は忘れましょう: SPA は code+PKCE または BFF、サーバー間は client_credentials、CLI は device flow です。
  • redirect URI は正確に: ワイルドカードなしの exact matching のみを使用します。
  • リフレッシュトークンは rotation + reuse detection: 窃取を前提としたライフサイクルを設計します。
  • MCP と AI エージェント時代の基盤レイヤー: 新しいシステムは最初から OAuth 2.1 + 必要に応じて FAPI 2.0 で設計することが、将来のコストを抑える道です。

draft が RFC 番号を取得する時期は重要ではありません。その内容はすでに RFC 9700 で義務化されており、主要な IdP とライブラリはすべて実装を終えているからです。今すぐマイグレーションを始めましょう。

参考資料