PKCE の不具合は典型的にはこう現れる。Web では認可コードフローが動くのに、モバイル SDK を更新したあと新しい端末でのログインがすべて invalid_grant で終わり、サーバーログには /token リクエストに code_verifier がない、あるいは先に送った challenge とハッシュが一致しない値が届いたと記録される。セッションがキャッシュされた端末は動き続けるのでテストで見落としやすく、調査はたいてい「どちらの値をハッシュし、どちらをどこへ送るのか」という問いから始まる。
PKCE は Proof Key for Code Exchange の略で、server secret を持たないクライアントでも認可リクエストの発行元であることを証明できるようにする OAuth の拡張仕様。RFC 7636 が 2015 年にモバイル・ネイティブアプリのために導入した。RFC 9700(2025 年)はパブリッククライアントに PKCE を必須とし、コンフィデンシャルクライアントにも推奨している。OAuth 2.1 ドラフト はすべてのクライアントに要求し、例外は OpenID Connect の nonce を正しく実装していると認可サーバーが判断できるコンフィデンシャルクライアントだけだ。仕組み自体は段落一つで説明できるほど小さい。しかしハッピーパスのテストは通るのに、実クライアントが最初のリトライをした瞬間に静かに壊れる――そんな実装ミスが起きやすい仕様でもある。ZeroTool の PKCE ジェネレーター はブラウザ内でペアを生成し、/authorize URL と /token 交換用の curl を同時に表示する。verifier が仕様の文字セットを外れていれば challenge の計算自体を拒否し、ログにある challenge が一致しないときはその原因も示す。
PKCE が実際に守っているもの
通常の OAuth 2.0 認可コードフローはこう動く。ユーザーがクライアントで「Acme でログイン」を押し、https://acme.example/authorize に client_id と redirect_uri 付きでリダイレクト。ログイン後、Acme が redirect_uri に戻し、クエリに不透明な code が乗ってくる。クライアントのバックエンドは https://acme.example/token でその code を access token と引き換える際、Acme と事前共有した client_secret で自分自身を認証する。
弱点はリダイレクトの部分。モバイルでは myapp://callback のハンドラを誰でも登録できる。SPA では code が window.location に一瞬現れ、URL を読める拡張機能ならすべて見える。攻撃者がクライアントより先に code を盗み、しかも client_id を知っていれば、自分で token 交換を完遂できる。
PKCE はその穴を塞ぐ。クライアントは /authorize に飛ばす前に code_verifier というランダムなシークレットを生成し、SHA-256 でハッシュ、base64url にエンコードして code_challenge を作る。/authorize に乗せるのは challenge――verifier ではない。サーバーは発行した code に challenge を紐付けて記録する。クライアントが code を持って /token に来るとき、元の code_verifier も送る。サーバーはそれをもう一度ハッシュして、記録した challenge と一致するか確かめる。code を盗んだ攻撃者は verifier を一度も見ていないので、二段階目のリクエストを偽造できない。
verifier は秘密、challenge は公開コミットメント。この二つの組み合わせで、code 盗聴攻撃が無効化される――交換の二段階目を完了するには、ネットワークに一度も流れていない知識が必要だからだ。
今すぐ PKCE が必要な五つのフロー
| クライアント種別 | PKCE が必要な理由 | /token の認証 |
|---|---|---|
| ネイティブモバイル(iOS / Android) | client_secret を安全に保管できない。RFC 7636 の本来の対象 | code_verifier のみ――Public Client |
| SPA(React / Vue / SvelteKit ハイドレート済み) | ブラウザで同じ問題。JS 上のものは拡張機能と DevTools に丸見え | code_verifier のみ――Public Client |
| ローカル callback サーバー付き CLI | http://127.0.0.1:PORT のループバックリダイレクトはローカルの任意プロセスが奪える | code_verifier のみ――Public Client |
| デスクトップアプリ(Electron・ネイティブ) | URL ハンドラはシステム規模で登録可能 | code_verifier のみ |
| Confidential Web App(サーバーレンダリング) | RFC 9700 §2.1.1 は推奨。OAuth 2.1 ドラフト §7.5.1.1 は、クライアントが OpenID Connect の nonce を正しく実装しているとサーバーが判断できる場合を除き必須 | code_verifier と client_secret 両方 |
最後の行が OAuth 2.1 へ移行するチームの見落としやすい点。サーバー側に client_secret を保持する Express バックエンドでも PKCE を使うべきだ。secret はクライアントを認証するだけで、盗んだ認可コードをそのクライアントのセッションに差し込む攻撃は防げないが、PKCE は防げる。そのため RFC 9700 はコンフィデンシャルクライアントにも PKCE を推奨し、OAuth 2.1 ドラフトは OpenID Connect の nonce という一つの例外を除いて必須とし、例外に当たる場合も推奨している。
ワークベンチを一度通して見る
PKCE ジェネレーター を開くと、スクリプトが動いた瞬間に verifier がレンダリングされる。デフォルトは 43 文字。crypto.getRandomValues で得た 32 バイトの乱数を base64url にしたもので、RFC 7636 §4.1 と §7.1 が示す作り方そのもの(256 ビット)だ。プロバイダーが長さを指定していれば 長さ を 43〜128 の範囲で変える。verifier の上の行に長さとエントロピーが出る。新しく生成(または Ctrl/⌘+Enter)で新しい verifier になり、challenge もすぐに再計算される。自分のアプリの verifier を貼り付ければ、RFC の文法で 1 文字ずつ調べて最初に問題のある文字を示す。
新しく生成 の隣が方式の選択で、S256 と plain がある。既定は S256。RFC 7636 §4.2 は S256 を使えるクライアントに S256 を義務づけ、plain は SHA-256 を計算できないクライアントにしか認めていない。OAuth 2.1 ドラフト §7.5.2 は plain を禁止した。ただしサーバーがみなこれを強制するわけではない。たとえば飛書(Feishu)の 認可コード取得 API は plain を受け付け、code_challenge_method がなければ plain として扱う。S256 を選び、方式は毎回明示しよう。
code_challenge のカードに表示されるのが実際に /authorize に乗せる値。ここからコピーする。verifier を作り直すたびに値が変わる――新しい verifier の新しいハッシュだからだ。
下の三つのパネルにはそれぞれ役割がある。code_challenge を照合 は、アプリが実際に送った値を貼り付けると、verifier と一致するか、典型的な誤り(末尾の =、標準 Base64、16 進数のダイジェスト、verifier そのもの、デコードした乱数バイト列のハッシュ)のどれかを判定する。認可リクエスト URL は認可エンドポイント・client_id・redirect_uri・scope を入れると、response_type=code・現在の challenge・code_challenge_method・ランダムな state、scope に openid があればランダムな nonce を含む完全な /authorize URL を作る。これをブラウザのアドレス欄に貼ればフロー第一段を実行でき、ログイン後にリダイレクト URL から code を拾える。
トークンリクエスト(cURL) はそれに対応する /token リクエストを生成する。リダイレクトで受け取った code を code 欄に貼り付けてコマンドを実行する。code_verifier body パラメータに入るのは verifier そのもの――challenge ではない。verifier と challenge が正しくペアになっていて code が新鮮なら、プロバイダーはトークンを返す。一致しなければ invalid_grant――本番で PKCE が壊れているときと同じエラーだ。まず code_challenge を照合 で調べよう。
暗号処理は本当に数行で済む
実質的に意味を持つ四行は声に出して読めるくらい短い。
// 1. ランダム verifier を生成
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const verifier = base64url(bytes); // 32 バイト → 43 文字
// 2. ハッシュして challenge にする
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = base64url(new Uint8Array(hash)); // 32 バイト → 43 文字
function base64url(bytes) {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
この手順で出る verifier は常に 43 文字――32 バイトをパディングなしの base64 で表すとちょうど 43 文字になる。RFC 7636 は 43〜128 文字を許容しているので 43 は最小値かつ完全に合法。ツールの既定値も 43 文字だ。RFC 7636 §7.1 は 256 ビット以上のエントロピーを求めており、32 バイトの乱数でちょうど満たせる。
ハッシュは SubtleCrypto で計算する。Web Crypto API の一部で、ページが HTTPS で配信されていれば現代の主要ブラウザはすべて使える。crypto.subtle が見つからない場合(よくあるのは http:// で開いてしまったとき)、ツールは実行を拒否しインラインのステータスメッセージで HTTPS への切り替えを促す。
Base64url は base64 の小改造:+ → -、/ → _、末尾の = パディングを削る。RFC 7636 が指定するのはこの形式だ。ここでのエンコードミスは典型的な静かなバグ。標準の btoa() の出力は + や / を含み末尾に = が付くので、サーバーが verifier から計算する base64url のハッシュとは決して一致せず、トークン取得は曖昧なエラーで失敗する。その値を code_challenge を照合 に貼れば、どのエンコードの誤りかをツールが示す。
ログインを静かに壊す五つのミス
1. challenge を /token に送ってしまう。 /authorize は challenge、/token は verifier。どちらも 43 文字の base64url 文字列なので、入れ替えても見た目で分からない。覚え方:challenge は公開コミットしたもの、verifier は自分が秘密に持っていたことを証明するもの。
2. code_challenge_method の不整合。 /authorize で S256 を宣言したのに、クライアント側で SHA-256 を計算し忘れて生 verifier を challenge として送ってしまった場合、サーバーは /token で受け取った verifier をハッシュしてから「あなたが challenge として送ってきた生 verifier」と比べるので、当然一致しない。method を送り忘れるのは逆向きの同じ誤りだ。RFC 7636 §4.3 では code_challenge_method がなければ plain とみなされ、正しい S256 の challenge が verifier そのものと比較される。method は challenge と必ずセットで送り、全体の既定を S256 にすること。
3. 再試行で verifier を使い回す。 認可フロー一回につき新しい verifier/challenge ペア。ユーザーが同意画面を閉じてしまい /authorize をリトライする場合、必ず新しい verifier を作る――サーバーは中断された前回の verifier を覚えている可能性がある。
4. verifier の保管場所を誤る。 SPA なら、下のブラウザ向けサンプルのように sessionStorage に置き、トークン取得が終わったら削除する。verifier は同じタブでリダイレクトを一度またげれば十分だ。sessionStorage はそのタブに属し、タブを閉じると消える。localStorage はログイン後も残り、同じオリジンのすべてのタブで共有されるので、二つのタブで同時にログインすると verifier を上書きし合う。どちらもスクリプト注入は防げない。オリジン上で動く JavaScript なら両方とも読める。ネイティブアプリはプラットフォームの安全ストレージを使う(iOS は Keychain、Android は Android Keystore の鍵で暗号化)。verifier を平文でディスクに書かないこと。
5.「S256 は面倒だから」と plain を選ぶ。 上記の数行が S256 の完全な実装で、OAuth ライブラリにも入っている。仕様に plain の出番はない。RFC 7636 §4.2 は SHA-256 を計算できないクライアントにしか認めず、RFC 9700 §2.1.1 は認可リクエストで verifier をさらさない方式は現在 S256 だけだとし、OAuth 2.1 ドラフト §7.5.2 は禁止している。サーバー側の扱いはまちまちで、LINE ログイン は S256 しか受け付けない一方、Keycloak はクライアント設定で plain を選べ、飛書は method がないと plain として扱う。
主要プロバイダーへの組み込み
code_challenge のパラメータ形状はプロバイダー間で共通――RFC 7636 が標準化している。違いは登録手続きだけ:「Public Client」や「Allow PKCE」のチェックを入れるか、どの redirect_uri スキームを許可するか。
Auth0 は ドキュメント で、client secret を保存できないアプリ(ネイティブアプリやシングルページアプリ)向けに PKCE 付き認可コードフローを説明しており、SDK がパラメータを付けてくれる。エンドポイントは https://YOUR_TENANT.auth0.com/authorize と https://YOUR_TENANT.auth0.com/oauth/token。
Okta の Authorization Code with PKCE ガイド は、パブリッククライアント用のアプリ種別である Native Application か Single-Page Application の統合を作るところから始まる。org 認可サーバーを使う場合、エンドポイントは https://YOUR_OKTA_DOMAIN/oauth2/v1/authorize と https://YOUR_OKTA_DOMAIN/oauth2/v1/token。
Keycloak はクライアントごとの PKCE method で設定する(Server Administration Guide)。空欄ならクライアントがパラメータを送ったときだけ PKCE を適用し、S256 か plain を選ぶとその方式が必須になる。S256 を選ぶ。エンドポイントは realm パターン https://YOUR_KEYCLOAK/realms/YOUR_REALM/protocol/openid-connect/{authorize,token}。
Amazon Cognito は認可コードグラントで PKCE をサポートしている。challenge は認可エンドポイント https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/authorize に、verifier は /oauth2/token に送る。
Spotify は Authorization Code with PKCE を、モバイルアプリ、シングルページアプリなど client secret を安全に保存できないアプリに推奨するフローとしている。パラメータはほかと同じだ。
三つの実装スニペット
Python、requests 使用:
import base64, hashlib, secrets, requests
verifier = secrets.token_urlsafe(64)[:64]
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
# 1. ユーザーを /authorize に飛ばす(challenge 付き)
# 2. redirect_uri で code を受け取る
# 3. 交換:
response = requests.post(
"https://acme.example/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://yourapp.example/callback",
"client_id": "YOUR_CLIENT_ID",
"code_verifier": verifier,
},
)
JavaScript / TypeScript(ブラウザ):
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(hash));
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = `https://acme.example/authorize?` + new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://yourapp.example/callback',
code_challenge: challenge,
code_challenge_method: 'S256',
state: crypto.randomUUID(),
});
location.href = authUrl;
function base64url(bytes: Uint8Array) {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
Bash、code を受け取ったあとの /token 段:
curl -X POST https://acme.example/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$AUTHORIZATION_CODE" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code_verifier=$CODE_VERIFIER"
なぜブラウザ内で動くジェネレーターに居場所があるか
公開された PKCE ジェネレーターのほとんどはすでに JS で暗号処理を完結している――そこは難しくない。違いはツールが周囲に何を一緒に提供するかだ。
tonyxu-io.github.io/pkce-generator はみんながブックマークしているあれだ。UI は最小限、RFC 7636 の例の出力は正確、英語のみ。2026-10-01 に試したところ入力の検証はなく、42 文字、+ / = を含む verifier、空欄のどれでも challenge が出た。Ping Identity の PKCE Code Generator はブラウザ内で生成するが、verifier 欄が読み取り専用なので自分のアプリの verifier は照合できない。oauth.com/playground はインタラクティブだが自社のテスト issuer に対するラウンドトリップを丸ごと組み立てる――学習用には素晴らしいが、自前のプロバイダーをデバッグするには重い。
ZeroTool のツールはその空白を埋める。ジェネレーターと貼り付け可能な /token curl を結びつけて実際の自社プロバイダーへ交換を再生できるようにし、貼り付けた verifier を RFC の文法で検証し、ログの challenge が一致しない原因を示し、同じインターフェイスを四言語で提供する。verifier はページ読み込みごとに新しく生成され、localStorage・sessionStorage・URL には書かれない――persistence policy が disabled に設定してあり、ページはアクセス解析と広告のスクリプトも読み込まない。デバッグセッションの合間にタブを再読み込みすれば前の verifier は消える――OAuth helper に期待される「無状態」挙動と一致する。
関連資料
- RFC 7636 — Proof Key for Code Exchange — 原典
- OAuth 2.1 ドラフト — OpenID Connect の nonce を使うコンフィデンシャルクライアント(§7.5.1.1)を除き PKCE を必須とし、
plainを禁止(§7.5.2) - RFC 9700 — OAuth 2.0 Security Best Current Practice — パブリッククライアントは PKCE 必須、コンフィデンシャルクライアントは推奨(§2.1.1)
- Web Crypto API:SubtleCrypto.digest — MDN 日本語版、SHA-256 呼び出しのリファレンス
- JWT デコーダー — PKCE 交換成功後に
/tokenが返した ID トークン、または JWT 形式の access token を確認 - JWT ジェネレーター — リソースサーバーのテスト用に自分で HS256 トークンを発行
- ハッシュジェネレーター — SHA-256・SHA-384・SHA-512 を手動で計算したいときに