OAuth 2.0のPKCE:横取りされた認可コードをトークンに換えさせない仕組み

PKCEのcode_verifierとcode_challengeが認可コード横取りをどう防ぐか。S256の計算、サーバー側の検証、plainとダウングレードの問題、stateとの境界、PKCEでは防げないことをRFC 7636とRFC 9700に基づいて整理します。

oauthpkcesecurityauthenticationrfc

OAuth 2.0の認可コード(authorization code)は、ブラウザやOSのリダイレクトを経由してクライアントに届きます。トークンエンドポイントへの通信と違い、この区間はクライアントが直接制御できません。PKCE(Proof Key for Code Exchange、RFC 7636)は、コードが途中で漏れても第三者がトークンに交換できないようにする拡張です。本記事では、PKCEが何を証明するのか、クライアントとサーバーがそれぞれ何を守るべきか、PKCEを入れても解決しない問題は何かを、RFCの原文に沿って整理します。

認可コードフローでコードが通る経路

RFC 6749の4.1節にある認可コードフローは、次の順で進みます。

  1. クライアントがユーザーのブラウザ(user-agent)を認可エンドポイントへ送ります。リクエストにはclient_id、redirect_uri、scope、stateなどが含まれます。
  2. 認可サーバーがユーザーを認証し、アクセスを許可するかどうかの判断を得ます。
  3. 認可サーバーがブラウザをredirect_uriへ戻し、クエリにcodeを付けます。
  4. クライアントがそのコードをトークンエンドポイントへ送り、アクセストークンを要求します。
  5. 認可サーバーがコードとリダイレクトURIを確認し、トークンを発行します。

RFC 6749は、認可コードは発行後すぐに失効すべきで(最大10分を推奨)、2回以上使ってはならず、クライアント識別子とリダイレクトURIに結び付けられると定めています。コンフィデンシャルクライアントなら4の段階でクライアント認証も行うため、コードだけを盗んでもトークンは得にくくなります。

問題はパブリッククライアントです。RFC 6749の2.1節は、端末にインストールされるネイティブアプリやブラウザ内で動くアプリを、資格情報の機密性を保てないパブリッククライアントに分類しています。秘密を持たないので、コードを手にした者が誰であれ、それをトークンエンドポイントに持ち込めば済んでしまいます。

RFC 7636が想定した攻撃

RFC 7636の1節はスマートフォンの例を挙げています。ネイティブアプリはリダイレクトURIにカスタムURIスキームを使うことが多く、悪意あるアプリが同じスキームのハンドラとして自分を登録すると、3のリダイレクトを受け取って認可コードを横取りできます。その後、悪意あるアプリが自分でトークンを要求します。

RFCはこの攻撃の前提条件も列挙しています。OSが複数のアプリに同じカスタムスキームの登録を許すこと、認可コードグラントが使われていること、攻撃者がclient_id(発行されていればclient_secretも)を知っていることです。同じネイティブアプリのインストールはすべて同じclient_idを使い、アプリのバイナリに埋め込んだ秘密は機密とみなせない、という指摘もあります。条件は多いものの、RFCはこの攻撃が実際に観測されていると記しています。

PKCEが追加する3つの値

PKCEは認可リクエストとトークンリクエストにそれぞれパラメータを足し、認可コードに結び付けられたchallengeに対応するverifierをトークンリクエストが提示しているかをサーバーが確かめられるようにします。

パラメータ 送信先 内容
code_challenge 認可リクエスト code_verifierを変換した値。必須
code_challenge_method 認可リクエスト S256またはplain。省略時はplain扱い
code_verifier トークンリクエスト クライアントがリクエストごとに新しく作る秘密の値。必須

クライアントは認可リクエストのたびに新しいcode_verifierを作って保持し、変換後のcode_challengeだけを認可リクエストに載せます。認可サーバーは発行するコードにそのchallengeとmethodを関連付けておきます。トークンリクエストが届くと、一緒に送られたcode_verifierを同じ方法で変換し、保存したchallengeと比較します。リダイレクトでコードを横取りした側はverifierを知らないので、トークンを得られません。RFC 7636がこれを「code verifierの所持の証明」と呼ぶのはこのためです。

code_verifierの形式とS256の計算

RFC 7636の4.1節によるcode_verifierの規則は次のとおりです。

  • 文字種:RFC 3986のunreserved文字、つまりA-Z、a-z、0-9、-、.、_、~
  • 長さ:43文字以上128文字以下
  • 推測が現実的でないだけのエントロピーを持つべき(SHOULD)

推奨される作り方は、適切な乱数生成器で32オクテットを生成し、base64urlでエンコードすることです。32オクテットは256ビットで、パディングなしでエンコードするとちょうど43文字になります。7.1節は最低256ビットのエントロピーを推奨(SHOULD)し、セキュリティモデル全体がverifierを攻撃者に知られない・推測されないことに依存していると強調しています。

S256の変換は次のように定義されています。

code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

ここでのbase64urlは、RFC 4648の5節にあるURL安全な文字集合(+の代わりに-、/の代わりに_)を使い、末尾の=をすべて取り除き、改行や空白を含めないエンコードです。通常のbase64の結果をそのまま使ったり、パディングを残したりすると、サーバー側の計算と一致しません。SHA-256の出力は32オクテットなので、S256のchallengeも43文字になります。

RFC 7636の付録Bにある例の値です。

code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

認可リクエスト:      code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
                     &code_challenge_method=S256
トークンリクエスト:  code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

自前で実装したなら、この組をテストベクタとして使えます。同じverifierから同じchallengeが得られなければなりません。

7.3節は、challengeにソルトを使わない理由も説明しています。verifier自体が256ビットのエントロピーを持つため、公開された値を連結してハッシュしても総当たりに必要な試行回数は増えない、というものです。パスワードのハッシュに似ていても、低エントロピーのパスワードとは前提が異なります。

サーバーがすべきこと

RFC 7636がサーバーに求める内容をまとめます。

  • 認可コードを発行するとき、code_challengeとcode_challenge_methodをそのコードに関連付けなければなりません(MUST)。コード自体に暗号化して埋め込んでも、サーバー側に保存してもかまいません。ただし、challengeを他者が取り出せる形でクライアントのリクエストに含めてはなりません(MUST NOT)。
  • PKCEを要求するサーバーがcode_challengeのない認可リクエストを受けたら、invalid_requestエラーを返さなければなりません(MUST)。要求された変換方式をサポートしていない場合も同様です。
  • トークンエンドポイントは、コード発行時に結び付けたmethodでcode_verifierを検証しなければなりません(MUST)。一致しなければinvalid_grantを返さなければなりません(MUST)。
  • S256はサーバーの必須実装(MTI)です。

RFC 7636の5節は、互換性のためにPKCEを使わないクライアントを受け入れてもよい(MAY)としています。この余地が、後述するダウングレード攻撃の出発点になります。

plainがS256と同等でない理由

plainはcode_challenge = code_verifierで、変換をしません。つまり認可リクエストにverifierがそのまま載ります。

RFC 7636は攻撃者を2段階に分けています。認可エンドポイントのレスポンスしか見られない攻撃者なら、plainでも防げます。しかし、OSのHTTPログの漏えいなどでリクエストまで見られる攻撃者に対しては、plainのchallengeはverifierそのものなので役に立たず、S256か暗号学的に安全な拡張方式が必要です。RFC 9700の3節も、認可リクエストを読める攻撃者(A4)を別に想定しています。

そのためRFC 7636は次のように定めています。

  • S256を使えるクライアントはS256を使わなければなりません(MUST)。plainが許されるのは、技術的な理由でS256を使えず、かつサーバーがplainをサポートしていると帯域外の設定で分かっている場合だけです。
  • 新しい実装ではplainを使うべきではありません(SHOULD NOT)。
  • S256を試した後にplainへ下げてはなりません(MUST NOT)。PKCEをサポートするサーバーはS256をサポートする必要があり、PKCE非対応のサーバーは未知のパラメータを無視するので、S256でエラーが出たならサーバーの不具合か中間者によるダウングレードの試みしかありえない、という理屈です。

code_challenge_methodを省略すると既定値がplainになる点にも注意が必要です。methodを付け忘れると、意図せず弱い方式で処理されます。

RFC 9700の2.1.1節は、現時点で認可リクエストにverifierを露出しない方式はS256だけだと明記しています。

PKCEダウングレード攻撃

RFC 9700の4.8節は、PKCEをサポートしていても必須にはしていないサーバーで起きる攻撃を説明しています。前提は2つです。サーバーがcode_challengeがあればPKCEを適用し、なければ適用しないこと。そして、クライアントがstateを使っていないか、正しく検証していないことです。

  1. 被害者がクライアントでログインを始めます。クライアントはcode_challenge=hash(abc)を送り、応答を待ちます。
  2. 攻撃者が自分の端末で同じクライアントのフローを開始します。クライアントが作ったcode_challenge=hash(xyz)を、攻撃者がリクエストから丸ごと削除します。自分の端末なので、ブラウザの開発者ツールでも可能です。
  3. サーバーはPKCEなしのフローとみなし、どのchallengeにも結び付かないコードを発行します。
  4. 攻撃者は、そのコードを含む認可レスポンスのURLへ被害者のブラウザを誘導します。
  5. クライアントは被害者のセッションのcode_verifier=abcを付けてコードを交換しようとします。
  6. このコードにはchallengeがないので、サーバーはverifierを確認せずにトークンを発行します。被害者のセッションに攻撃者のリソースが結び付けられます。

RFC 9700は、認可サーバーがこの攻撃を緩和しなければならない(MUST)としています。方法は単純で、認可リクエストにcode_challengeがなかったのにトークンリクエストにcode_verifierが含まれていれば拒否します。すべてのフロー(または特定のクライアント)にPKCEを必須とするサーバーは、この条件を自然に満たします。また、クライアントが有効なcode_challengeを送った場合、サーバーはトークンエンドポイントでcode_verifierの正しい使用を強制しなければなりません(MUST)。

state、nonceとの境界

RFC 6749はリダイレクトエンドポイントのCSRF対策をクライアントに求め(MUST)、その値をstateで運ぶべき(SHOULD)としています。RFC 9700の2.1節はここに条件を加えます。

  • 認可サーバーがPKCEをサポートしていると確認したクライアントは、PKCEによるCSRF保護に頼ってもよい(MAY)。
  • OpenID ConnectのフローではnonceがCSRF保護を提供する。
  • それ以外の場合は、ユーザーエージェントに安全に結び付けたワンタイムのCSRFトークンをstateに入れて使わなければならない(MUST)。

つまり「PKCEを使うからstateは不要」が成り立つのは、サーバーのPKCEサポートを確認した場合だけです。RFC 9700の4.7.1節は、サーバーがPKCEをサポートしていなければstateかnonceを使わなければならない(MUST)とし、サーバーは自らのPKCEサポートを検出できる手段を提供しなければならない(MUST)としています。推奨される方法は、認可サーバーメタデータ(RFC 8414)のcode_challenge_methods_supportedです。前述のダウングレード攻撃のとおり、サーバー側の強制がないままPKCEだけを信じてstateを省いたクライアントは、攻撃の前提条件を1つ満たしてしまいます。stateにアプリケーションの状態を載せていて、その完全性が重要なら、改ざんやすり替えから保護する必要があるという要件も別にあります。

nonceは認可コード注入への対策としても、PKCEの代替として挙げられています。RFC 9700は、追加の条件を守るコンフィデンシャルなOpenID ConnectクライアントであればPKCEの代わりにnonceを使ってもよい(MAY)とする一方、nonceはパブリッククライアントのコードを保護しないと明言しています。攻撃者は注入する必要がなく、トークンエンドポイントを直接呼べばよいからです。

RFC 7636とRFC 9700の役割の違い

2つの文書は要件のレベルが異なるので、混ぜずに読むのが安全です。

項目 RFC 7636(2015年、標準化トラック) RFC 9700(2025年、BCP 240)
誰がPKCEを使うか パブリッククライアント向けに設計。クライアントはすべてのサーバーにパラメータを送るべき(SHOULD) パブリッククライアントは必須(MUST)、コンフィデンシャルクライアントも推奨(RECOMMENDED)。Webアプリを含むすべてのクライアントが対象
サーバーの対応 PKCE非対応クライアントを受け入れてよい(MAY)。対応するならS256は必須実装 認可サーバーはPKCEをサポートしなければならない(MUST)
方式 S256が使えるならS256必須(MUST)、plainは例外的に許容 verifierを露出しない方式を使うべき(SHOULD)。現時点ではS256のみ
ダウングレード クライアントはS256を試した後にplainへ下げてはならない challengeなしで発行したコードにverifierが来たらサーバーが拒否しなければならない(MUST)
サポートの公開 規定なし 検出手段の提供は必須(MUST)、メタデータの公開を推奨
値の性質 リクエストごとに新しいverifier challengeはトランザクションごとに固有で、クライアントとユーザーエージェントに結び付けなければならない(MUST)。サーバーは固定値の使用を検出するよう努めることが推奨される

RFC 9700はRFC 6749、6750、6819を更新(Updates)するBCPで、既存のセキュリティ上の助言を置き換えるのではなく補完するものだと自ら述べています。RFC 9700はRFC 7636を更新対象に挙げていないため、表の右列はRFC 7636の規範的な文言ではなく、その上に重ねられた現在のベストプラクティスとしての要件です。

PKCEでは防げないこと

PKCEが証明するのは「この認可トランザクションに結び付けられたchallengeに対応するverifierを持っている」ということだけです。それ以外は別の仕組みの担当です。

  • ユーザー認証ではありません。ユーザーを認証するのはフローの2の段階での認可サーバーです。PKCEは誰がログインしたかについて何も示しません。
  • クライアントの身元確認でもありません。コードを特定のクライアントインスタンスとトランザクションに結び付けるだけで、そのクライアントが正規のアプリであることは証明しません。
  • リダイレクトURIの検証を置き換えません。RFC 9700は登録済みリダイレクトURIとの完全な文字列一致(localhostを使うネイティブアプリのポート番号のみ例外)をサーバーに求め(MUST)、RFC 6749もトークンリクエストのredirect_uriが認可リクエストと同一であることの確認を求めています。さらにRFC 9700の4.5.4節は、被害者の認可リクエストに含まれるcode_challengeを攻撃者が書き換えられるならPKCEを迂回してコードを注入できると述べ、だからこそ認可レスポンスの漏えいを防ぐ対策が引き続き必要だとしています。
  • オープンリダイレクタを防ぎません。RFC 9700は、クライアントと認可サーバーがオープンリダイレクタを公開してはならない(MUST NOT)と別途定めています。
  • 発行済みのトークンは守りません。PKCEの検査はコードをトークンに交換する瞬間にしか行われません。アクセストークンが漏れた場合はRFC 9700が推奨する送信者制約(mTLS、DPoP)やオーディエンス制限が必要で、パブリッククライアントのリフレッシュトークンは送信者制約かローテーションを使わなければなりません(MUST)。
  • サーバーのサポートを確認していなければCSRF対策にはなりません。前節の条件がそのまま当てはまります。
  • 複数の認可サーバーとやり取りするクライアントのミックスアップ攻撃には、issパラメータなど別の対策が必要です(RFC 9700の2.1節)。

実装チェックリスト

クライアント

  • 認可リクエストごとに暗号学的な乱数源から新しいcode_verifierを作り、そのフローが終わるまでだけ保持する。
  • code_challenge_method=S256を明示する。省略するとplainになる。
  • base64urlエンコードでパディングを除き、-と_の文字集合を使っているかを付録Bの値で確認する。
  • S256が拒否されてもplainで再試行しない。
  • メタデータなどでサーバーのPKCEサポートを確認していないなら、CSRF対策としてstateを使い続ける。

認可サーバー

  • 発行するコードにchallengeとmethodを結び付け、トークンエンドポイントでそのmethodを使って検証する。不一致はinvalid_grant。
  • challengeなしで発行したコードにcode_verifierが付いてきたら拒否する。可能ならすべてのフローでPKCEを必須にする。
  • メタデータでcode_challenge_methods_supportedを公開する。
  • リダイレクトURIの完全一致、コードの短い有効期限と一回限りの使用といった既存の要件も引き続き守る。

出典

ブログへ