攻撃手法から学ぶOAuth 2.0セキュリティ完全ガイド|CSRF・認可コードインジェクション・トークン漏洩をRFC 9700で防ぐ
攻撃手法から学ぶOAuth 2.0セキュリティ完全ガイド|CSRF・認可コードインジェクション・トークン漏洩をRFC 9700で防ぐ
はじめに:なぜ「正しく実装したはずのOAuth」が破られるのか
ライブラリに任せていれば安全、という誤解
「Auth0のSDKを使っているから大丈夫」「Keycloakを立てたから認証周りは安心」——そう思っているエンジニアは少なくありません。しかし現実は厳しく、SDKやサービスを使っていても、デフォルト設定のままでは2026年の脅威モデルに対して脆弱なケースが多数存在します。
たとえばPKCE(Proof Key for Code Exchange)は2015年に策定された仕様ですが、多くの認可サーバー製品では「デフォルト無効」のまま長年放置されていました。クライアントがPKCEを実装してリクエストしていても、サーバー側が強制していなければ、攻撃者は単純にPKCEパラメータを取り除いてリクエストするだけで保護を無効化できてしまいます。
「動く実装」と「安全な実装」の間には、深い溝があります。OAuthの仕様は巨大で、RFCが十数本に分散しており、それぞれの要素が組み合わさって初めて安全性が担保されます。一つの対策が他の設定不備によって無力化される——そういった「連鎖的な落とし穴」が、実務で何度も見過ごされてきました。
本記事のゴール
本記事では、以下の3点を達成することをゴールとしています。
- 代表的な7種類の攻撃を「攻撃者の視点」で理解し、なぜその対策が必要なのかを腹落ちさせる
- RFC 9700(OAuth 2.0 Security Best Current Practice)に準拠したチェックリストを手に入れる
- 自社の認可フローを今日中に監査できる状態になる
コードサンプルは実際の開発現場で使えるものを提示しています。セキュリティの理論だけでなく、実装の具体的な手がかりを持ち帰っていただけるように構成しました。
想定読者
- 認可基盤・SaaS連携を実装するバックエンド/フロントエンドエンジニア
- OAuth 2.0を「なんとなく」使っているが、コードレビューで深く指摘できる自信がない方
- OAuth 2.1/RFC 9700への移行を検討中のテックリード・セキュリティ担当者
前提知識の整理:OAuth 2.0の登場人物と信頼境界
4つのロールと2つのチャネル
OAuth 2.0の登場人物は4つです。リソースオーナー(エンドユーザー)、クライアント(あなたが作っているアプリ)、認可サーバー(Googleや自社のIdPなど)、リソースサーバー(保護されたAPIを提供するサーバー)。
これらが通信するチャネルは2種類に分かれます。フロントチャネルはブラウザのリダイレクトを介した通信で、URLやクエリパラメータが目に見える形でユーザーのブラウザを経由します。バックチャネルはサーバー間の直接HTTP通信で、外部から観測されにくい経路です。
この区別が重要な理由は単純です。OAuthに対する攻撃のほぼすべてはフロントチャネルで起きます。ブラウザは攻撃者がコントロールできる領域に近く、URLは記録・漏洩・改ざんの対象になりえます。認可コードや認可リクエストのパラメータがフロントチャネルを通る以上、その取り扱いに細心の注意を払わなければなりません。
認可コードフローの正常系を理解する
現在のベストプラクティスである認可コードフローの正常系は以下のステップで構成されます。
1. クライアント → 認可サーバー(フロントチャネル)
認可リクエスト(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
2. 認可サーバー → クライアント(フロントチャネル)
認可コード付きリダイレクト(code, state)
3. クライアント → 認可サーバー(バックチャネル)
トークンリクエスト(code, code_verifier, client_id, redirect_uri)
4. 認可サーバー → クライアント(バックチャネル)
アクセストークン、リフレッシュトークン
各ステップで「誰が何を検証すべきか」が明確に定義されています。クライアントはコールバックでstateを検証し、認可サーバーはトークンリクエスト時にcode_verifierとcode_challengeを突き合わせます。この検証の連鎖が崩れた箇所が、攻撃の入り口になります。
脅威モデル:攻撃者は何を狙っているのか
OAuthに対する攻撃の目的は大きく3つに分類できます。
- アカウント乗っ取り:被害者のアカウントに攻撃者を紐付ける(または攻撃者のアカウントを被害者のセッションで利用させる)
- トークンの窃取と不正利用:アクセストークンやリフレッシュトークンを盗み、被害者に成りすましてAPIを叩く
- クライアント・認可サーバーのなりすまし:正規のクライアントや認可サーバーを偽装して認証情報を詐取する
この記事で扱う攻撃手法の全体マップ
攻撃を4カテゴリに分類する
| カテゴリ | 代表的な攻撃 | 主要な防御 |
|---|---|---|
| フロー横取り系 | CSRF、Mix-Up攻撃 | state、issパラメータ |
| コード/トークン注入系 | 認可コードインジェクション、アクセストークンインジェクション | PKCE、nonce |
| 漏洩・再利用系 | トークン漏洩、IDトークンリプレイ | 短命化、DPoP、mTLS |
| 実装不備系 | リダイレクトURI検証不足、PKCEダウングレード | 完全一致検証、サーバー側強制 |
「対策は知っているのに防げない」典型パターン
多くの実装者が陥るのは、「対策を知っていて実装しているのに、別の箇所の設定不備によって無効化されている」というパターンです。
- PKCEをクライアント側に実装したが、認可サーバーがPKCEなしのリクエストも受け付けている
stateパラメータを生成して送信しているが、コールバック時の照合処理を書き忘れている- リダイレクトURIを登録しているが、前方一致で検証しているため抜け道がある
ここから先は、各攻撃の具体的な攻撃シナリオ・脆弱なコード・修正コード・検証手順まで踏み込んで解説します。
攻撃①:リダイレクトURI検証不足 ― すべての入口となる脆弱性
攻撃シナリオ:オープンリダイレクトから認可コードを奪う
リダイレクトURI検証の不備は、OAuthセキュリティにおける最も基本的かつ致命的な脆弱性です。認可コードはフロントチャネル(ブラウザのリダイレクト)を介して返却されるため、リダイレクト先を攻撃者がコントロールできれば認可コードを丸ごと奪えます。
最も古典的な攻撃は、前方一致マッチングを突くものです。認可サーバーがhttps://example.com/callbackから始まるURIをすべて許可するよう設定されていた場合、攻撃者はhttps://example.com.attacker.jp/callbackというURIを使って正規サイトに見せかけることができます。
他にも以下のような抜け道があります。
# 登録済みURI
https://example.com/callback
# 攻撃者が試みるURI
https://example.com/callback/../evil # パストラバーサル
https://example.com/callback?redirect=https://attacker.jp # クエリパラメータ追加
https://example.com%2Fattacker.jp/callback # URLエンコードを使った混乱
さらに危険なのが、サブドメインテイクオーバーと組み合わせた攻撃です。https://*.example.com/callbackというワイルドカード登録をしている場合、old-service.example.comのような使われていないサブドメインを攻撃者が取得してしまうと、そこが正規の認可リダイレクト先になります。
実装アンチパターン集
正規表現によるURI検証は、エスケープの漏れや文字コードの差異によって予期しない抜け道が生まれやすく、セキュリティ用途には適しません。
# ❌ 危険な実装例(前方一致による検証)
def is_valid_redirect_uri(uri, registered_uris):
for registered in registered_uris:
if uri.startswith(registered): # 前方一致は危険
return True
return False
# ❌ 危険な実装例(正規表現による検証)
import re
def is_valid_redirect_uri(uri, pattern):
return re.match(pattern, uri) is not None # パターンの抜け道が生まれやすい対策:完全一致(Exact String Matching)の徹底
RFC 9700が要求するのはシンプルです。登録されたURIと要求されたURIをバイト単位で完全一致させることです。
# ✅ 正しい実装例(完全一致による検証)
def is_valid_redirect_uri(requested_uri, registered_uris):
# 文字列の完全一致のみを許可
return requested_uri in registered_uris
# 認可サーバー側の検証例
REGISTERED_REDIRECT_URIS = {
"client_app_1": [
"https://app.example.com/callback",
"https://app.example.com/oauth2/callback",
]
}
def validate_authorization_request(client_id, redirect_uri):
if client_id not in REGISTERED_REDIRECT_URIS:
raise AuthorizationError("unknown_client")
allowed_uris = REGISTERED_REDIRECT_URIS[client_id]
if redirect_uri not in allowed_uris:
raise AuthorizationError("invalid_redirect_uri")ローカル開発用にhttp://localhost:3000/callbackを登録した場合は、本番環境にデプロイする前に必ず削除してください。localhostエントリが本番認可サーバーに残っていると、ローカルネットワーク上の攻撃者に悪用される可能性があります。
攻撃②:CSRF ― 攻撃者のアカウントに被害者を紐付ける
攻撃シナリオ:ログインCSRFの本当の怖さ
OAuthにおけるCSRF攻撃は、Webフォームに対する一般的なCSRFとは異なる危険性を持っています。OAuthのCSRF(ログインCSRFとも呼ばれます)では、攻撃者のアカウントに被害者を紐付けることが目的です。
攻撃の手順を追ってみましょう。
- 攻撃者は自分のアカウントで認可フローを開始し、認可コードが返ってくる手前で止める(コールバックURLを取得しておく)
- そのコールバックURLを被害者に踏ませる(メール・SNS・画像埋め込みなどで誘導)
- 被害者のブラウザが攻撃者の認可コードをサービスに送信し、被害者のセッションと攻撃者のアカウントが紐付けられる
- 以降、被害者がサービスで行った操作(個人情報の登録、クレジットカードの追加など)はすべて攻撃者のアカウントに反映される
この攻撃は、ECサイトの「Googleでログイン」機能を標的にした場合に特に深刻です。被害者が気づかないまま攻撃者のアカウントで利用を続け、クレジットカード情報を登録してしまうシナリオが現実に発生しています。
stateパラメータの正しい使い方
CSRF対策の本命はstateパラメータです。正しく実装するための要件を整理します。
① 暗号学的乱数による生成
import secrets
import hashlib
import base64
def generate_state():
# 256ビット(32バイト)以上の暗号学的乱数
raw = secrets.token_bytes(32)
# Base64URLエンコード
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
state = generate_state()
# 例: "dGhpcyBpcyBhIHNlY3VyZSBzdGF0ZSB2YWx1ZQ"random.random()や時刻ベースの値は絶対に使用しないでください。予測可能な値は攻撃者に推測されます。
② セッションへのバインドとコールバック時の検証
# 認可リクエスト開始時
def start_authorization(session):
state = generate_state()
# セッションにstateを保存(有効期限付き)
session['oauth_state'] = state
session['oauth_state_expires_at'] = time.time() + 600 # 10分
auth_url = build_authorization_url(
client_id=CLIENT_ID,
redirect_uri=REDIRECT_URI,
scope="openid profile email",
state=state,
code_challenge=generate_code_challenge(),
code_challenge_method="S256"
)
return redirect(auth_url)
# コールバック処理時
def handle_callback(request, session):
received_state = request.args.get('state')
stored_state = session.get('oauth_state')
expires_at = session.get('oauth_state_expires_at', 0)
# ① stateが存在するか確認
if not received_state or not stored_state:
raise SecurityError("state parameter missing")
# ② 有効期限チェック
if time.time() > expires_at:
raise SecurityError("state has expired")
# ③ 定数時間比較(タイミング攻撃対策)
if not secrets.compare_digest(received_state, stored_state):
raise SecurityError("state mismatch - possible CSRF attack")
# 使用済みstateを削除(ワンタイム性の担保)
del session['oauth_state']
del session['oauth_state_expires_at']
# 以降、認可コードの処理へ
process_authorization_code(request.args.get('code'))stateとPKCE、結局どちらを使うべきか
よく聞かれる質問です。stateはCSRF対策、PKCEは認可コードインジェクション対策と、防ぐ脅威が異なります。PKCEはCSRF攻撃に対しても一定の保護効果がありますが、RFC 9700は両方の使用を推奨しています。実務上の結論は「両方使う」です。互いに補完関係にあり、実装コストも大きくありません。
攻撃③:認可コードインジェクション ― PKCEが必須である理由
攻撃シナリオ:盗んだ認可コードを被害者のセッションに注入する
認可コードインジェクション攻撃では、攻撃者が何らかの方法で入手した認可コードを、別の被害者のセッションに注入してトークンを不正取得します。
認可コードが漏れる経路は複数あります。
- ブラウザの履歴やブックマーク(コールバックURLごと保存される)
- HTTPアクセスログやプロキシのログ(Refererヘッダに前のURLが含まれる)
- APMツールやエラートラッキングツール(リクエストURLをそのまま記録するもの)
- フィッシングサイトやオープンリダイレクトを悪用した誘導
「認可コードは1回しか使えない」という仕様だけでは不十分です。攻撃者は盗んだコードを自分のセッションではなく被害者のセッションで消費させることで、被害者のアカウントにアクセスするトークンを取得できます。
PKCE(RFC 7636)の内部動作を完全理解する
PKCEは認可コードインジェクション攻撃を根本的に防ぐ仕組みです。
認可リクエスト時:
code_verifier = 暗号学的乱数(43〜128文字)
code_challenge = BASE64URL(SHA256(code_verifier))
→ code_challengeとcode_challenge_method="S256"を送信
トークンリクエスト時:
→ code_verifierを送信
認可サーバーの検証:
BASE64URL(SHA256(受け取ったcode_verifier)) == 保存していたcode_challenge
コードで実装すると以下のようになります。
import secrets
import hashlib
import base64
def generate_pkce_pair():
# code_verifierの生成(RFC 7636: 43〜128文字、unreserved characters)
# secrets.token_urlsafe(96)は128文字のbase64url文字列を生成
code_verifier = secrets.token_urlsafe(96)[:128]
# code_challengeの生成(S256メソッド)
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
return code_verifier, code_challenge
# 使用例
code_verifier, code_challenge = generate_pkce_pair()
# 認可リクエスト
auth_params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile",
"state": generate_state(),
"code_challenge": code_challenge,
"code_challenge_method": "S256", # 必ずS256を使用
}
# code_verifierはセッションに安全に保存しておく
session['pkce_code_verifier'] = code_verifiercode_challenge_methodにplainを指定すると、code_challengeそのものがcode_verifierと等しくなります。これは、傍受した認可リクエストからcode_challengeを取り出すだけでcode_verifierが手に入ることを意味し、PKCEの保護が完全に無効化されます。plainは絶対に使用しないでください。
【最重要】PKCEダウングレード攻撃という盲点
現場で最も見落とされやすい攻撃がPKCEダウングレード攻撃です。
クライアントが正しくPKCEを実装してリクエストを送っても、認可サーバーがPKCEなしのリクエストも受け付けるよう設定されていれば、攻撃者はcode_challengeパラメータを削除してプロキシするだけで保護を無力化できます。
通常のリクエスト(保護あり):
/authorize?client_id=app&code_challenge=abc123&code_challenge_method=S256&...
攻撃者がPKCEを削除したリクエスト:
/authorize?client_id=app&...(code_challenge なし)
↓
認可サーバーがPKCEなしを許可 → 攻撃成立
この問題の対策は認可サーバー側でPKCEを強制することです。各製品での設定方法を確認してください。
Auth0の場合: ダッシュボード → Applications → 対象アプリ → Settings → Advanced Settings → OAuth タブで「PKCE Always Required」を有効化。
Keycloakの場合:
Realm Settings → Clients → 対象クライアント → Advanced → Proof Key for Code Exchange Code Challenge Method を S256 に設定し、Plain と None を無効化。
Authleteの場合:
クライアント設定で requirePKCE: true を設定することでPKCEなしのリクエストを拒否。
confidential clientにもPKCEは必要か
「サーバーサイドのアプリだからクライアントシークレットがある。PKCEは不要では?」という疑問は自然です。しかしRFC 9700は明確にすべてのクライアントタイプでPKCEを要求しています。
理由は認可コードインジェクション攻撃がクライアントシークレットの有無に関わらず成立するからです。トークンリクエストにはクライアントシークレットが必要ですが、攻撃者はトークンリクエストを行うのではなく、被害者のセッションに認可コードを注入するだけです。サーバー側のクライアントはそのコードを自身のシークレットで正当にトークンに換えてしまいます。PKCEはこの「コード単体では使えない」仕組みを提供するため、confidential clientでも必須です。
攻撃④:アクセストークン漏洩とインジェクション
トークンが漏れる7つの経路
アクセストークンは一度漏れると、有効期限が切れるまで攻撃者に悪用され続けます。漏洩の経路を把握することが対策の第一歩です。
- URLフラグメント(Implicitフロー):
#access_token=xxxの形でブラウザの履歴に残る - Refererヘッダ:トークンを含むURLから他のページに遷移した際に漏洩する
- アクセスログ:WebサーバーがリクエストURLをそのまま記録している場合
- APM・エラートラッキングツール:DatadogやSentryがリクエストヘッダを記録するケース
- localStorage + XSS:JavaScriptからアクセス可能なlocalStorageとXSSの組み合わせ
- サードパーティスクリプト:分析タグやチャットウィジェットがlocalStorageをスキャンする
- エラー画面・スタックトレース:例外ハンドラがリクエストオブジェクトごとログ出力するケース
Implicitフローを今すぐ廃止すべき理由
ImplicitフローはアクセストークンをフロントチャネルのURLフラグメントに直接含めて返す設計です。これはバックチャネルによるトークン取得を省略するために考案された仕組みでしたが、2026年現在、SPAでも認可コードフロー+PKCEを使うことが技術的に容易になり、Implicitフローの採用理由はなくなりました。
RFC 9700はImplicitフローを明確に非推奨(SHOULD NOT)としています。SPAでの移行手順は以下の通りです。
// ❌ Implicitフロー(廃止すべき)
const authUrl = `https://auth.example.com/authorize?
response_type=token // ← フラグメントでトークンが返ってくる
&client_id=${CLIENT_ID}
&redirect_uri=${REDIRECT_URI}`;
// ✅ 認可コードフロー + PKCE(SPAでも推奨)
async function startAuth() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// code_verifierはsessionStorageに一時保存(ページリロード対策)
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code'); // ← 認可コードを要求
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'openid profile');
authUrl.searchParams.set('state', generateState());
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
}
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const encoded = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', encoded);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}トークン保存戦略の比較
| 保存先 | XSS耐性 | CSRF耐性 | 実装難度 | 推奨度 |
|---|---|---|---|---|
| localStorage | × | ○ | 低 | 非推奨 |
| sessionStorage | △ | ○ | 低 | 非推奨 |
| メモリのみ(変数) | ○ | ○ | 中 | 条件付き推奨 |
| HttpOnly Cookie | ○ | 要対策 | 中 | 推奨 |
| HttpOnly Cookie + BFF | ○ | ○ | 高 | 最推奨 |
BFF(Backend for Frontend)パターンでは、SPAはトークンを直接保持せず、専用のバックエンドサーバーを介してAPIリクエストを行います。トークンはBFFのサーバーサイドセッションにのみ存在し、ブラウザにはHttpOnly Cookieのセッションキーだけが残ります。XSSが発生してもJavaScriptからトークンを盗めないため、最も強固な構成です。
攻撃⑤:Mix-Up攻撃 ― 複数認可サーバー環境の落とし穴
攻撃シナリオ:クライアントを悪意ある認可サーバーに誘導する
「Googleでログイン」「Facebookでログイン」「GitHubでログイン」を並べたマルチIdP構成のアプリを狙った攻撃です。
攻撃の流れは以下の通りです。
- 攻撃者が悪意ある認可サーバーを用意し、正規の認可サーバーと同じ
client_idを登録 - ユーザーが「Googleでログイン」を選んだタイミングで、攻撃者がリクエストを傍受し、Googleではなく攻撃者の認可サーバーへ誘導
- 攻撃者の認可サーバーは、Googleに対してユーザーの認可コードを要求するリダイレクトを返す
- 最終的にGoogleの認可コードが攻撃者のサーバーを経由してクライアントに届く
- クライアントはGoogle向けのトークンリクエストを行い、攻撃者はその通信を傍受してトークンを取得する
対策1:issパラメータ(RFC 9207)の検証
RFC 9207では、認可レスポンスにiss(Issuer)パラメータを含めることを定義しています。
# コールバック処理でissを検証する
def handle_callback(request, session):
received_iss = request.args.get('iss')
expected_iss = session.get('expected_issuer') # 認可リクエスト開始時に保存
if received_iss != expected_iss:
raise SecurityError(f"Issuer mismatch: got {received_iss}, expected {expected_iss}")
# 以降の処理...対策2:認可サーバーごとに異なるリダイレクトURIを割り当てる
より確実な方法として、認可サーバーごとに専用のコールバックエンドポイントを設ける設計があります。
/oauth/callback/google → Google専用
/oauth/callback/github → GitHub専用
/oauth/callback/okta → Okta専用
コールバックエンドポイントのパスを見るだけで「どの認可サーバーのレスポンスか」が確定するため、Mix-Up攻撃が成立しません。
攻撃⑥:IDトークンリプレイとnonce(OpenID Connect)
IDトークン検証で見落とされる項目
OpenID Connectのid_tokenには検証すべきクレームが複数あります。見落とされやすいものを中心に確認します。
import jwt # PyJWT
def validate_id_token(id_token, expected_nonce):
# JWKSを取得して公開鍵を動的に解決
jwks_client = jwt.PyJWKClient("https://auth.example.com/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
payload = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256", "ES256"], # noneアルゴリズムは絶対に含めない!
audience=CLIENT_ID,
issuer="https://auth.example.com",
options={
"verify_exp": True, # 有効期限(必須)
"verify_iat": True, # 発行時刻(必須)
"verify_aud": True, # 対象クライアント(必須)
"verify_iss": True, # 発行者(必須)
}
)
# nonceの検証(リプレイ攻撃対策)
if payload.get('nonce') != expected_nonce:
raise SecurityError("nonce mismatch - possible replay attack")
# 使用済みnonceを無効化(ワンタイム管理)
invalidate_nonce(expected_nonce)
return payloadalgorithmsにnoneを含めることは絶対に禁止です。アルゴリズムをnoneにした署名なしJWTを受け入れる実装は、数年前から繰り返し指摘されてきた致命的バグです。ライブラリのデフォルト設定でnoneが受け入れられないことを必ず確認してください。
nonceの役割と実装
nonceはIDトークンのリプレイ攻撃を防ぐためのワンタイム値です。stateがOAuthフローのCSRF対策であるのに対し、nonceはOpenID Connectにおけるid_tokenの再利用を防ぎます。
# 認可リクエスト開始時
nonce = secrets.token_urlsafe(32)
session['oidc_nonce'] = nonce
# コールバック処理時
validate_id_token(id_token, expected_nonce=session.get('oidc_nonce'))攻撃⑦:DPoP関連攻撃 ― 送信者制約トークンの最前線
なぜBearerトークンでは不十分なのか
従来のアクセストークン(BearerトークンまたはBearer scheme)は、HTTPヘッダにAuthorization: Bearer <token>と添えるだけで使えます。これはトークンを「持っているだけで使える」ことを意味します。万一トークンが漏洩すると、有効期限が切れるまで誰でも悪用できてしまいます。
送信者制約(Sender-Constrained)トークンは、「このトークンは特定の鍵ペアを持つクライアントしか使えない」という縛りをトークンに付与する発想です。
DPoP(RFC 9449)の仕組み
DPoP(Demonstrating Proof-of-Possession)では、クライアントが鍵ペアを生成し、APIリクエストのたびに「この鍵ペアを持つ私がこのリクエストを送っている」という証明(DPoP Proof JWT)を添付します。
// DPoP Proof JWTの生成(ブラウザのWeb Crypto APIを使用)
async function generateDPoPProof(keyPair, htm, htu, accessToken) {
const header = {
typ: "dpop+jwt",
alg: "ES256",
jwk: await crypto.subtle.exportKey("jwk", keyPair.publicKey)
};
const payload = {
htm: htm, // HTTPメソッド("GET", "POST"など)
htu: htu, // リクエストURI(クエリパラメータなし)
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(), // ワンタイムID(リプレイ防止)
// アクセストークンのハッシュ値(トークンとProofを紐付ける)
ath: await computeHash(accessToken)
};
return signJWT(header, payload, keyPair.privateKey);
}
// APIリクエスト時
const proof = await generateDPoPProof(keyPair, "GET", "https://api.example.com/data", accessToken);
fetch("https://api.example.com/data", {
headers: {
"Authorization": `DPoP ${accessToken}`,
"DPoP": proof
}
});DPoP Proofリプレイ攻撃と対策
攻撃者がDPoP Proofを傍受し、同じProofを使い回して不正アクセスを試みる攻撃です。jti(JWT ID)と有効期限の組み合わせで防ぎます。
サーバー側では受け取ったjtiを短期間(DPoP Proofの有効期限である30秒〜1分程度)キャッシュし、同じjtiが再度来た場合はリジェクトします。さらにRFC 9449ではサーバーがDPoP-Nonceヘッダでnonceを発行し、クライアントが次のProofにそのnonceを含めることを義務付ける仕組みも定義されています。
DPoP vs mTLS(RFC 8705):どちらを選ぶか
| 観点 | DPoP | mTLS |
|---|---|---|
| 適するクライアント | SPA・モバイル・パブリック | サーバー間・機密クライアント |
| インフラ要件 | 低(アプリ層で完結) | 高(証明書運用・LB設定) |
| 実装コスト | 中 | 中〜高 |
| 証明書失効の仕組み | 不要 | 必要(CRL/OCSP) |
SPAやモバイルアプリにはDPoP、サービス間通信やB2B連携にはmTLSが適しています。両者は排他ではなく、エンドポイントの性質に応じて使い分けることも一般的です。
クライアントなりすましと動的登録の危険性
パブリッククライアントにおける client_id の限界
ネイティブアプリはカスタムURIスキーム(myapp://callbackなど)をリダイレクトURIに使うケースが多いですが、OSレベルでは同じカスタムスキームを複数アプリが登録できてしまいます。悪意あるアプリが正規アプリと同じカスタムスキームを登録すれば、認可コードが横取りされる可能性があります。
対策として、iOS/AndroidともにUniversal Links / App Links(httpsスキームを使った検証済みディープリンク)を使うことを推奨します。これらはOSがドメイン所有者の検証ファイルを確認するため、他のアプリには乗っ取られません。
クライアント認証方式の比較
機密クライアント(サーバーサイドアプリ)では、トークンリクエスト時のクライアント認証が必要です。認証方式の推奨度を整理します。
| 方式 | 説明 | 推奨度 |
|---|---|---|
client_secret_basic |
Basic認証でシークレット送信 | 低〜中(シークレットの管理が課題) |
client_secret_post |
POSTボディにシークレット送信 | 低(ログに残りやすい) |
private_key_jwt |
クライアントの秘密鍵でJWT署名 | 高(シークレット不要) |
| mTLS | クライアント証明書による認証 | 高(インフラ整備が前提) |
クライアントシークレットをアプリのバイナリやソースコードに埋め込むことは絶対に避けてください。GitHubに公開されたリポジトリからシークレットが漏洩する事故は今も継続して発生しています。環境変数や秘密管理サービス(AWS Secrets Manager、HashiCorp Vaultなど)を使って管理してください。
RFC 9700準拠:2026年版ベストプラクティス総まとめ
必須設定チェックリスト(コピーして使える形式)
認可フロー
- [ ] すべてのクライアントで認可コードフロー+PKCE(S256)を強制
- [ ] 認可サーバー側でPKCEなしリクエストを拒否
- [ ] Implicitグラントを無効化
- [ ] ROPCグラント(Resource Owner Password Credentials)を無効化
リダイレクトURIと検証
- [ ] リダイレクトURIは完全一致検証(バイト単位)
- [ ] ワイルドカード・前方一致登録を禁止
- [ ] localhost エントリを本番環境から削除
状態管理とCSRF
- [ ] stateを暗号学的乱数(CSPRNG)で生成(256ビット以上)
- [ ] stateをセッションにバインドし、コールバックで照合
- [ ] nonceをOpenID Connect利用時に必ず使用
- [ ] issパラメータ(RFC 9207)を検証(マルチIdP構成の場合)
トークンライフサイクル
- [ ] アクセストークン有効期限は15〜30分
- [ ] リフレッシュトークンローテーションを有効化
- [ ] リフレッシュトークン再利用検知(Refresh Token Rotation + Reuse Detection)
- [ ] スコープは最小権限(最小スコープ)
高度なセキュリティ
- [ ] 高リスクAPIにはDPoP(RFC 9449)またはmTLS(RFC 8705)を適用
- [ ] DPoP使用時はサーバー発行nonceを強制
- [ ] IDトークンの署名検証でnoneアルゴリズムを排除
- [ ] JWKSキャッシュに適切なTTLを設定(キーローテーション対応)
段階的移行ロードマップ
フェーズ1(〜1週間):監査とリスク棚卸し
まず現状を把握します。ブラウザのDevToolsで認可リクエストURLを確認し、code_challenge・state・response_typeの有無をチェックします。認可サーバーの設定画面でPKCEの強制設定を確認し、Implicitフローが有効になっていないか確認します。
フェーズ2(〜1ヶ月):PKCE強制とリダイレクトURI是正
PKCEの実装とサーバー側での強制設定、リダイレクトURI登録の完全一致化を実施します。stateパラメータの生成・検証ロジックも見直します。
フェーズ3(〜3ヶ月):Implicit廃止、BFF導入
SPAでImplicitフローを使っている場合は認可コードフロー+PKCEへ移行します。トークン保存をlocalStorageからBFF+HttpOnly Cookieへ移行します。
フェーズ4(〜6ヶ月):DPoP/mTLS導入、トークンライフサイクル最適化
高リスクAPIにDPoP送信者制約を適用し、リフレッシュトークンのローテーション設定を最適化します。
OAuth 2.1へのマッピング
OAuth 2.1ドラフトは、これまでの拡張仕様(RFC 7636, RFC 9700など)を一本化した仕様です。主な変更点はPKCEの必須化・Implicitの削除・ROPCの削除です。今RFC 9700に対応しておけば、OAuth 2.1への移行は追加作業がほぼゼロになります。
実践:自社の認可フローを監査する手順
ブラウザDevToolsだけでできる10分診断
ブラウザの開発者ツールを開き、「Googleでログイン」などのOAuth連携ボタンをクリックします。Networkタブまたはアドレスバーに表示されるURLから以下を確認します。
確認するパラメータ:
✅ response_type=code (codeであることを確認。tokenはNG)
✅ code_challenge=... (PKCEが実装されているか)
✅ code_challenge_method=S256 (S256であることを確認。plainはNG)
✅ state=... (存在し、長いランダム文字列であること)
意図的にリクエストを改変して検証する
Burp Suiteやmitmproxyを使ってリクエストを改変することで、認可サーバーの設定を確認できます。
# PKCEダウングレード検査:code_challengeを削除して送信
# → エラーになればOK、通ってしまえば脆弱
# state改ざん検査:stateの値を変えてコールバックを送信
# → エラーになればOK、無視されれば脆弱
# リダイレクトURI検査:URIにサフィックスを追加
# https://app.example.com/callback → https://app.example.com/callback.evil
# → エラーになればOK、通ってしまえば脆弱よくある質問(FAQ)
Q. stateとPKCE、両方必要ですか?
A. 両方使うことを推奨します。 stateはCSRF対策、PKCEは認可コードインジェクション対策と防ぐ脅威が異なります。PKCEがCSRF対策を兼ねられる条件もRFC 9700に記述されていますが、実装コストは軽微なので両方実装しておくのが安全です。
Q. 機密クライアント(サーバーサイド)でもPKCEは要りますか?
A. RFC 9700は明確に「要る」と言っています。 クライアントシークレットは認可コードインジェクション攻撃を防ぎません。PKCEは認可コード単体を盗んでも無意味にする仕組みであり、クライアントタイプに関わらず有効です。
Q. リフレッシュトークンはどこに保存するのが正解ですか?
A. HttpOnly Cookie + BFF構成を推奨します。 SPAでのメモリ保存はページリロードで消えるため、リフレッシュトークンには不向きです。BFFパターンでサーバーサイドに保管し、HttpOnly Cookieでセッション管理するのがXSS耐性・使い勝手の両面で最適です。
Q. DPoPとmTLSの使い分けの基準は?
A. クライアントの種別で選んでください。 SPAやモバイルアプリにはDPoP、サーバー間APIや機密クライアントにはmTLSが適しています。証明書インフラが整備されているならmTLSの方が強力ですが、整備コストが高いのも事実です。
Q. マイクロサービス間のトークン伝播はどう設計すべき?
A. Token Exchange(RFC 8693)を検討してください。 サービスAが受け取ったアクセストークンをそのままサービスBへ転送する「トークン伝播」は、スコープの過剰付与やトークン誤用につながります。RFC 8693のToken Exchangeでサービス間の信頼関係を明示し、必要最小権限のトークンを再発行する設計が推奨されます。
Q. レガシーシステムでImplicitを外せない場合の緩和策は?
A. 短命なアクセストークン(5分以内)と厳格なCORS設定で被害を限定します。 ただしこれは時限的な緩和策です。Implicitフローを廃止できない根本原因(CORS設定の制約、レガシーライブラリの制約など)を特定し、移行計画を立てることを強く推奨します。
まとめ:攻撃者の視点を持つことが最大の防御になる
本記事の要点10選
- フロントチャネルは常に危険——URLを経由するすべての値は漏洩・改ざんのリスクがある
- リダイレクトURIは完全一致——前方一致・ワイルドカードは攻撃の入口になる
- stateは必ず検証する——生成するだけでコールバックで照合しなければ意味がない
- PKCEはS256のみ——plainメソッドは保護を無効化する
- PKCEはサーバー側でも強制する——クライアントだけの実装では不十分
- Implicitフローは廃止——SPAも認可コードフロー+PKCEへ移行する
- トークンはlocalStorageに置かない——BFF+HttpOnly Cookieが最強の組み合わせ
- マルチIdP構成ではissを検証する——Mix-Up攻撃は静かに認可コードを奪う
- IDトークンのnonceを検証する——リプレイ攻撃はOIDC利用時の盲点になりやすい
- 高リスクAPIにはDPoPを適用する——BearerトークンはトークンID盗難で即悪用される
明日から着手すべき3つのアクション
- 認可リクエストURLをDevToolsで確認する:
code_challengeとstateが存在するか、response_type=codeになっているかを今すぐ確認してください。 - 認可サーバーのPKCE強制設定を確認する:Auth0ならダッシュボード、KeycloakならRealm設定から確認し、PKCEなしリクエストが拒否される設定になっているかチェックします。
- リダイレクトURI登録の棚卸し:localhostや古いURLが残っていないか確認し、完全一致検証が実装されているかを確認します。
さらに深く学ぶための参考文献
- RFC 9700 — OAuth 2.0 Security Best Current Practice(本記事のベース)
- RFC 7636 — PKCE仕様の原文
- RFC 9207 — Authorization Server Issuer Identification(issパラメータ)
- RFC 9449 — DPoP(Demonstrating Proof-of-Possession)
- RFC 8705 — mTLS(Mutual TLS)によるクライアント認証
- Zenn書籍「攻撃手法から学ぶ OAuthセキュリティベストプラクティス」(hidebike712 / Authlete著)——本記事の参考書籍。各攻撃の詳細シナリオと対策が実装レベルで解説されており、実務エンジニアに強くお勧めします
- PortSwigger Web Security Academy — OAuth authentication — OAuthの脆弱性ラボが無料で利用でき、実際に攻撃を手で動かしながら学べる最良の実習環境です
OAuthのセキュリティは「一度設定したら終わり」ではありません。新しい攻撃手法が発見されるたびに、RFCは更新され、ベストプラクティスは進化します。攻撃者の視点を持ち続けることが、長期的に安全な認可基盤を維持する最大の武器になります。
関連記事
PGSimCityとは?PostgreSQLの内部動作をシムシティ風3Dで可視化する学習ツール徹底解説
PGSimCityとは?PostgreSQLの内部動作をシムシティ風3Dで可視化する学習ツール徹底解説 「SQLは書けるし、インデックスも張っている。でも、なぜこのクエリが遅いのかを説明できない」——そんな経験に心当たりはないでしょうか。あるいは「autovacuumが走っているのにテーブルが膨張し続けている」という現象に頭を抱えたことはないでしょうか。 PostgreSQLの内部構造は、文章や図...
Substackだけで書くのは危険?独自サイト+POSSE戦略で自分のコンテンツを守る方法
Substackだけで書くのは危険?独自サイト+POSSE戦略で自分のコンテンツを守る方法 2026年6月、テクノロジー系の議論が集まるHacker Newsで394点を獲得した記事がありました。マレーシアのライターElizabeth Tai氏による「Substack writers, you need a website!」です。その冒頭に書かれた一文が、多くの技術者の胸に刺さりました。 > 「...
Claudeが「AIの使われ方」を語り出す──Anthropic Economic Index Connector 完全ガイド
Claudeが「AIの使われ方」を語り出す──Anthropic Economic Index Connector 完全ガイド 2026年7月23日、AnthropicはClaude向けの新機能「Economic Index Connector」をリリースしました。これにより、claude.aiユーザーはわずか2クリックの操作で、約100万件の会話分析データに自然言語で問い合わせられるようになりま...
Next.js Cloudflare Turso 本番
Next.js × Cloudflare × Turso で作る本番環境:エッジで動く高速Webアプリの完全構築ガイド はじめに:なぜこのスタックなのか Webアプリケーションの開発において、「高速」「安価」「スケーラブル」を同時に実現するスタック選定は、現代の開発者にとって最重要課題のひとつです。従来のVPSやコンテナベースの構成では、グローバルな低レイテンシを達成するためにマルチリージョン展開...