DPoP徹底解説:盗まれたOAuthトークンを無力化する完全ガイド
アクセストークンはBearerトークンです。つまり、トークン文字列を手に入れた人がいたら、ログファイルから盗んだのか、CDNが侵害されたのか、XSS脆弱性を利用したのか、中間者攻撃だったのかに関係なく、正規ユーザーとまったく同じように使えてしまう。トークンは誰が持っているか知らないし、気にもしない。
これが現代のOAuthデプロイメントにおける根本的なセキュリティの弱点で、10年以上にわたる公然の秘密なんです。セキュリティ監査のたびに指摘され、脅威モデルのたびに認識される。それなのに実用的な対策は複雑すぎるか(ブラウザクライアントにmTLS?)、限定的すぎるか(短いトークン寿命は爆発半径を縮めるだけで、爆発そのものは防がない)だった。
DPoP(Demonstrating Proof-of-Possession)がこの構図を変えます。RFC 9449で定義され、FAPI 2.0で必須のsender-constrainedトークンを実現する2つのメカニズムの1つ(もう一つはmTLS)になりました。仕組みはシンプルで、トークンをクライアントの秘密鍵に暗号学的にバインドする。トークンを持っているだけじゃ意味がなくて、すべてのAPIリクエストで「この鍵、確かに自分が持ってます」という暗号学的証明を付ける必要がある。盗まれたトークン?ただの文字列になります。
このガイドでは全部カバーします。Bearerトークンがなぜ根本的に危険なのか、DPoPの暗号学がどう動くのか、TypeScriptによるクライアント・サーバー両方の完全実装、リプレイ防止のためのノンス処理、プラットフォーム別の鍵ストレージ戦略、そしてBearerからSender-Constrainedトークンへの実践的なマイグレーションパスまで。
Bearerトークン問題の本質
Bearerトークンは現金と同じです。紙幣を持っている人が使える。本人確認なし、PINなし、生体認証なし。これは意図的な設計判断で、RFC 6750はBearerトークンを「bearerトークンを保有するすべての当事者が、暗号学的鍵の所有を証明することなく関連リソースにアクセスできる」トークンと定義しています。
このシンプルさのおかげでOAuth 2.0の普及は速かった。同時にトークン盗難が壊滅的に効果的になったのも事実です。
トークンが盗まれる経路
攻撃面はかなり広いんです:
トークン漏洩ベクター:
1. XSS → document.cookieやlocalStorageの読み取り
2. ログ集約 → URLパラメータやヘッダーのトークンがログに残る
3. CDN/プロキシ → 中間サービスがAuthorizationヘッダーをキャッシュまたはログ
4. ブラウザ拡張 → 悪意ある拡張機能がリクエストヘッダーを読む
5. 中間者攻撃 → 侵害されたTLS終端
6. 依存関係サプライチェーン → 侵害されたnpmパッケージがトークンを流出
2025年のVerizon DBIRレポートによると、トークン盗難はエンタープライズ環境におけるMFAバイパス技術の31%を占め、盗まれた資格情報は全体の侵害の22%に存在しています。短いトークン寿命は窓を縮めますが、15分のアクセストークンは攻撃者にとって15分間のフルAPIアクセスなんですよね。リフレッシュトークンローテーションも役立ちますが、ローテーション前にリフレッシュトークン自体が盗まれれば長期アクセスが可能になります。
既存の対策が不十分な理由
| 対策 | 限界 |
|---|---|
| 短いトークン寿命 | 窓は縮めるが盗難自体は防げない。5分トークンでも5分間のアクセス。 |
| リフレッシュトークンローテーション | レースコンディション:攻撃者がローテーション前に使用。発行時に盗まれれば無意味。 |
| トークンバインディング (RFC 8471) | ブラウザの広範なサポートを得られず。事実上死亡。 |
| mTLS (RFC 8705) | セキュリティは優秀だがブラウザクライアントでは非現実的。証明書管理の負担が膨大。 |
| HttpOnlyクッキー | XSSは防ぐがCSRFリスクが発生し、クロスオリジンAPIでは機能しない。 |
DPoPはまさにスイートスポットを押さえています。TLSクライアント証明書なしで、ブラウザ、モバイルアプリ、サーバー間通信のすべてで動くアプリケーション層セキュリティなんですよね。
DPoPの動き方
DPoPのコンセプトはシンプルです。クライアントが鍵ペアを生成し、毎回のリクエストで「この鍵、確かに持ってます」と証明し、サーバーがトークンをその鍵に紐づける:
DPoPフロー:
1. クライアントが非対称鍵ペアを生成(例:EC P-256)
2. クライアントが認可サーバーにトークンをリクエスト
→ 秘密鍵で署名したDPoP証明JWTを含める
→ 証明に含まれるもの:HTTPメソッド、対象URL、一意ID、タイムスタンプ
3. 認可サーバーが証明を検証しトークンを発行
→ トークンに公開鍵サムプリントを含む'cnf'クレームが入る
4. クライアントがリソースサーバーを呼び出す
→ トークン + 新しいDPoP証明(署名済み、トークンハッシュ付き)
5. リソースサーバーが検証:
→ 証明の署名がトークンにバインドされた鍵と一致するか
→ HTTPメソッドとURLが実際のリクエストと一致するか
→ 証明が新鮮か(タイムスタンプ + オプショナルノンス)
→ トークンハッシュが提示されたトークンと一致するか
ここがポイントです。攻撃者がアクセストークンを盗んでも、秘密鍵がなければ有効なDPoP証明は作れない。トークンを盗んだところで使い物にならないわけです。
DPoP証明JWTの構造
すべてのリクエストには、特定の構造を持つ署名済みJWTであるDPoP証明が含まれます:
DPoP証明JWTの構造:
HEADER:
{
"typ": "dpop+jwt", // 必ずこの値でなければならない
"alg": "ES256", // 非対称アルゴリズム(ES256、RS256など)
"jwk": { // 公開鍵(トークンリクエスト時)
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
PAYLOAD:
{
"htm": "POST", // リクエストのHTTPメソッド
"htu": "https://auth.example.com/token", // 対象URL
"iat": 1712400000, // 発行時刻(Unixタイムスタンプ)
"jti": "unique-id-abc123", // 一意ID(リプレイ防止)
"ath": "fUHyO2r2Z3DZ..." // アクセストークンハッシュ(リソースサーバーリクエスト時)
"nonce": "server-nonce" // サーバー提供ノンス(要求時)
}
2つの重要なクレームがDPoPを通常のJWTと区別します:
-
ath(Access Token Hash): リソースサーバー呼び出し時のみ存在。アクセストークンのSHA-256ハッシュをbase64urlエンコードした値です。証明を特定のトークンにバインドし、他のトークンでの再利用を防止します。 -
nonce: サーバーがDPoP-Nonceレスポンスヘッダーで提供する不透明な値。jtiの一意性チェックを超えたサーバーサイドのリプレイ防止を可能にします。
完全実装:クライアント側
TypeScriptでDPoPクライアントを実際に組んでみましょう。鍵生成にWeb Crypto API、JWT操作にjoseライブラリを使います。
鍵ペアの生成
// dpop-client.ts import { SignJWT, exportJWK, calculateJwkThumbprint } from 'jose'; import { v4 as uuidv4 } from 'uuid'; interface DPoPKeyPair { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey; thumbprint: string; } async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { // エクスポート不可のEC P-256鍵ペアを生成 const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', }, false, // エクスポート不可:秘密鍵はエクスポートできない ['sign', 'verify'] ); const publicJwk = await exportJWK(keyPair.publicKey); const thumbprint = await calculateJwkThumbprint( publicJwk as Parameters<typeof calculateJwkThumbprint>[0], 'sha256' ); return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, publicJwk, thumbprint, }; }
generateKeyのfalseがキモです。秘密鍵をエクスポート不可にするんですよね。同じコンテキストで実行されるJavaScriptコードでさえ生の鍵データは読めない。鍵はブラウザの暗号エンジン内にだけ存在します。
DPoP証明の作成
interface DPoPProofOptions { keyPair: DPoPKeyPair; method: string; url: string; accessToken?: string; // リソースサーバーリクエスト時は必須 nonce?: string; // サーバー提供ノンス } async function createDPoPProof(options: DPoPProofOptions): Promise<string> { const { keyPair, method, url, accessToken, nonce } = options; const payload: Record<string, unknown> = { htm: method.toUpperCase(), htu: url, jti: uuidv4(), iat: Math.floor(Date.now() / 1000), }; // リソースサーバーリクエスト時にアクセストークンハッシュを追加 if (accessToken) { const encoder = new TextEncoder(); const tokenBytes = encoder.encode(accessToken); const hashBuffer = await crypto.subtle.digest('SHA-256', tokenBytes); const hashArray = new Uint8Array(hashBuffer); payload.ath = base64urlEncode(hashArray); } // サーバー提供ノンスがあれば追加 if (nonce) { payload.nonce = nonce; } const proof = await new SignJWT(payload) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: keyPair.publicJwk, }) .sign(keyPair.privateKey); return proof; } function base64urlEncode(buffer: Uint8Array): string { const binary = String.fromCharCode(...buffer); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); }
DPoP対応 HTTPクライアント
ここが一番大事な部分です。ノンスの自動リトライを含む、DPoPのライフサイクル全体をハンドリングするHTTPクライアントラッパー:
class DPoPClient { private keyPair: DPoPKeyPair | null = null; private nonces: Map<string, string> = new Map(); // origin → nonce async initialize(): Promise<void> { this.keyPair = await generateDPoPKeyPair(); } async fetch( url: string, options: RequestInit & { accessToken?: string } = {} ): Promise<Response> { if (!this.keyPair) throw new Error('DPoPクライアント未初期化'); const method = options.method || 'GET'; const origin = new URL(url).origin; const proof = await createDPoPProof({ keyPair: this.keyPair, method, url, accessToken: options.accessToken, nonce: this.nonces.get(origin), }); const headers = new Headers(options.headers); headers.set('DPoP', proof); if (options.accessToken) { headers.set('Authorization', `DPoP ${options.accessToken}`); } const response = await fetch(url, { ...options, headers }); // ノンスチャレンジの処理 const newNonce = response.headers.get('DPoP-Nonce'); if (newNonce) { this.nonces.set(origin, newNonce); if (response.status === 401 || response.status === 400) { const wwwAuth = response.headers.get('WWW-Authenticate') || ''; if (wwwAuth.includes('use_dpop_nonce')) { return this.fetch(url, options); // 保存済みノンスでリトライ } } } return response; } }
DPoP付きトークンリクエスト
async function requestToken( client: DPoPClient, tokenEndpoint: string, authCode: string, codeVerifier: string ): Promise<{ access_token: string; refresh_token: string }> { const response = await client.fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, code_verifier: codeVerifier, client_id: 'my-spa-client', redirect_uri: 'https://app.example.com/callback', }).toString(), }); if (!response.ok) { throw new Error(`トークンリクエスト失敗: ${response.status}`); } return response.json(); }
AuthorizationスキームがBearerからDPoPに変わっているのがわかりますか?これがリソースサーバーに「DPoP証明も検証して」と伝えるシグナルです。
完全実装:サーバー側
サーバー側の責務は2つ。発行時にトークンを鍵にバインドすることと、リソースアクセス時に証明を検証することです。
トークン発行(認可サーバー)
import { jwtVerify, importJWK, calculateJwkThumbprint } from 'jose'; interface DPoPProofPayload { htm: string; htu: string; jti: string; iat: number; ath?: string; nonce?: string; } async function validateDPoPProof( proofJwt: string, expectedMethod: string, expectedUrl: string, expectedNonce?: string, accessToken?: string ): Promise<{ thumbprint: string }> { // 1. 公開鍵を抽出するため検証なしでヘッダーをデコード const [headerB64] = proofJwt.split('.'); const header = JSON.parse(atob(headerB64)); // 2. ヘッダーの検証 if (header.typ !== 'dpop+jwt') { throw new Error('無効なtyp: dpop+jwtでなければなりません'); } if (!header.jwk) { throw new Error('ヘッダーにjwkがありません'); } if (header.alg === 'none' || header.alg.startsWith('HS')) { throw new Error('DPoPに対称アルゴリズムは使用できません'); } // 3. 公開鍵をインポートし署名を検証 const publicKey = await importJWK(header.jwk, header.alg); const { payload } = await jwtVerify(proofJwt, publicKey, { typ: 'dpop+jwt', maxTokenAge: '60s', // 60秒以上前の証明を拒否 }); const claims = payload as unknown as DPoPProofPayload; // 4. HTTPメソッドとURLの検証 if (claims.htm !== expectedMethod) { throw new Error(`htmの不一致: ${expectedMethod}を期待、${claims.htm}を受信`); } if (claims.htu !== expectedUrl) { throw new Error(`htuの不一致: ${expectedUrl}を期待、${claims.htu}を受信`); } // 5. jtiの一意性を検証(リプレイキャッシュを確認) const isReplay = await checkAndStoreJti(claims.jti, 300); if (isReplay) { throw new Error('DPoP証明のリプレイを検出'); } // 6. 要求時にノンスを検証 if (expectedNonce && claims.nonce !== expectedNonce) { throw new Error('無効または欠落したDPoPノンス'); } // 7. アクセストークンハッシュの検証(リソースサーバーリクエスト時) if (accessToken) { const expectedAth = await computeAth(accessToken); if (claims.ath !== expectedAth) { throw new Error('アクセストークンハッシュの不一致'); } } // 8. トークンバインディング用のJWKサムプリントを計算 const thumbprint = await calculateJwkThumbprint(header.jwk, 'sha256'); return { thumbprint }; } async function computeAth(accessToken: string): Promise<string> { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest( 'SHA-256', encoder.encode(accessToken) ); return base64urlEncode(new Uint8Array(hashBuffer)); } // Redisベースのjtiリプレイキャッシュ async function checkAndStoreJti( jti: string, windowSeconds: number ): Promise<boolean> { const key = `dpop:jti:${jti}`; const exists = await redis.exists(key); if (exists) return true; await redis.setex(key, windowSeconds, '1'); return false; }
確認(Confirmation)クレーム付きトークン
認可サーバーがDPoPバインドトークンを発行する際、JWKサムプリントを含むcnf(confirmation)クレームを含めます:
async function issueToken( userId: string, dpopThumbprint: string, scopes: string[] ): Promise<string> { const token = await new SignJWT({ sub: userId, scope: scopes.join(' '), cnf: { jkt: dpopThumbprint, // JWKサムプリント確認 }, token_type: 'DPoP', }) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setExpirationTime('15m') .setIssuer('https://auth.example.com') .setAudience('https://api.example.com') .sign(serverPrivateKey); return token; }
リソースサーバーの検証
// middleware/dpop-validator.ts import { jwtVerify, decodeJwt } from 'jose'; async function validateDPoPRequest(req: Request): Promise<void> { // 1. ヘッダーからDPoP証明を抽出 const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { throw new DPoPError(401, 'DPoP証明ヘッダーが欠落'); } // 2. アクセストークンを抽出 const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('DPoP ')) { throw new DPoPError(401, '無効な認可スキーム、DPoPが期待されます'); } const accessToken = authHeader.slice(5); // 3. トークンをデコードしてバインドされたサムプリントを取得 const tokenClaims = decodeJwt(accessToken); const boundThumbprint = (tokenClaims.cnf as { jkt: string })?.jkt; if (!boundThumbprint) { throw new DPoPError(401, 'トークンがDPoPバインドされていません(cnf.jkt欠落)'); } // 4. DPoP証明を検証 const requestUrl = new URL(req.url).origin + new URL(req.url).pathname; const { thumbprint } = await validateDPoPProof( dpopProof, req.method, requestUrl, getCurrentNonce(), accessToken ); // 5. 証明がトークンにバインドされた鍵で署名されているか確認 if (thumbprint !== boundThumbprint) { throw new DPoPError(401, 'DPoP証明の鍵がトークンバインディングと不一致'); } } class DPoPError extends Error { constructor( public status: number, message: string ) { super(message); } }
リプレイ防止のためのノンス管理
jtiクレームがクライアントサイドの一意性を提供しますが、転送中の証明にアクセスできる攻撃者はタイムウィンドウ内でリプレイできる可能性があります。サーバー提供ノンスが2番目の防御レイヤーを追加します:
ノンスの動作
ノンスチャレンジフロー:
クライアント → リソースサーバー: DPoP証明(ノンスなし)
リソースサーバー → クライアント: 401 + DPoP-Nonce: "abc123"
クライアント → リソースサーバー: DPoP証明(nonce: "abc123")
リソースサーバー → クライアント: 200 OK + DPoP-Nonce: "def456"
クライアント → リソースサーバー: DPoP証明(nonce: "def456")
...継続、ノンスはレスポンスごとにローテーション...
サーバーサイドのノンス実装
class NonceManager { private currentNonce: string; private previousNonce: string | null = null; private rotationInterval: NodeJS.Timeout; constructor(rotationSeconds: number = 30) { this.currentNonce = this.generate(); // 定期的にノンスをローテーション this.rotationInterval = setInterval(() => { this.previousNonce = this.currentNonce; this.currentNonce = this.generate(); }, rotationSeconds * 1000); } private generate(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64urlEncode(bytes); } getCurrent(): string { return this.currentNonce; } isValid(nonce: string): boolean { // ローテーション中の猶予期間として現在と直前のノンスの両方を受け入れる return nonce === this.currentNonce || nonce === this.previousNonce; } } // Expressミドルウェア function dpopNonceMiddleware(nonceManager: NonceManager) { return (req: Request, res: Response, next: NextFunction) => { // すべてのレスポンスに現在のノンスを含める res.setHeader('DPoP-Nonce', nonceManager.getCurrent()); next(); }; }
ノンスに関する重要なルール
-
ノンスはサーバーごとに分離。 認可サーバーのノンスとリソースサーバーのノンスは完全に別物です。一方のサーバーのノンスを他方に使うのはNG。
-
ノンスは不透明な値。 クライアントはノンス値をパース、デコード、解釈してはいけません。不透明な文字列として扱ってください。
-
直前のノンスも受け入れる。 ローテーション中は現在と直前の両方のノンスを受け入れないと、飛行中のリクエストが壊れます。
-
ノンスヘッダーは常に送る。 成功レスポンスにも
DPoP-Nonceを含めてください。クライアントが失敗なしで次のリクエストのノンスを事前に取得できます。
鍵ストレージ戦略
DPoPのセキュリティは、秘密鍵を絶対にエクスポートさせないことにかかっています。プラットフォームによってアプローチが違うんですよね:
ブラウザ(SPA)
// IndexedDBで鍵ペアを永続化 async function persistKeyPair(keyPair: CryptoKeyPair): Promise<void> { const db = await openDB('dpop-keys', 1, { upgrade(db) { db.createObjectStore('keys'); }, }); // CryptoKeyオブジェクトはIndexedDBに直接保存可能 // 永続化してもnon-extractable属性は維持される await db.put('keys', keyPair, 'dpop-keypair'); } async function loadKeyPair(): Promise<CryptoKeyPair | null> { const db = await openDB('dpop-keys', 1); return db.get('keys', 'dpop-keypair'); }
重要なポイント:IndexedDBに保存されたCryptoKeyオブジェクトはextractable: falseプロパティを保持します。ページリロードを挟んでも生の鍵データがJavaScriptに露出することはありません。
モバイル(React Native / ネイティブ)
| プラットフォーム | ストレージ | セキュリティレベル |
|---|---|---|
| iOS | Secure Enclave(Keychain) | ハードウェア保護、改ざん不可 |
| Android | Android Keystore(StrongBox対応時) | 対応デバイスでハードウェア保護 |
| React Native | react-native-keychain + プラットフォーム固有バッキング | プラットフォームに依存 |
サーバー間通信
バックエンドサービスでは、環境変数やKMSから鍵ペアをロードできます:
// 環境変数またはKMSを使用 const privateKey = await importPKCS8( process.env.DPOP_PRIVATE_KEY!, 'ES256' );
マイグレーション:BearerからDPoPへ
すべてのクライアントに一夜でDPoPを強制するのは無理な話です。段階的に進めましょう:
フェーズ1:デュアルサポート
BearerとDPoPトークンの両方を受け入れる。新しいクライアントはDPoP、既存クライアントはBearerを継続。
async function validateRequest(req: Request): Promise<TokenClaims> { const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('DPoP ')) { // DPoPフロー:証明 + トークンバインディングを検証 await validateDPoPRequest(req); return decodeAndVerifyToken(authHeader.slice(5)); } if (authHeader?.startsWith('Bearer ')) { // レガシーBearerフロー:まだ受け入れる metrics.increment('auth.bearer.used'); // 廃止追跡用 return decodeAndVerifyToken(authHeader.slice(7)); } throw new Error('Authorizationヘッダーが欠落または無効'); }
フェーズ2:DPoPをデフォルトに
デフォルトでDPoPバインドトークンを発行。Bearerトークンの使用をログに記録して監視。
app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (dpopProof) { // DPoP対応クライアント:sender-constrainedトークンを発行 const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); } else { // レガシークライアント:廃止警告付きBearerトークンを発行 const token = await issueBearerToken(userId, scopes); res.setHeader('Deprecation', 'true'); res.json({ access_token: token, token_type: 'bearer' }); } });
フェーズ3:DPoP必須
モニタリングでBearer使用率の低下を確認後、すべてのクライアントにDPoPを強制。
app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { return res.status(400).json({ error: 'invalid_dpop_proof', error_description: 'DPoP証明が必要です。Bearerトークンはもう受け付けていません。', }); } const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); });
マイグレーション指標
| 指標 | 目標 | 何がわかるか |
|---|---|---|
| DPoP採用率 | フェーズ3前に90%以上 | 全体のマイグレーション進捗 |
| Bearerトークン使用率 | 5%未満に低下 | レガシークライアントが更新しているか |
| ノンスリトライ率 | リクエストの10%未満 | ノンスローテーションが積極的すぎないか |
| 証明検証失敗率 | 0.1%未満 | クロックスキューまたは実装バグ |
| 鍵ローテーションイベント | クライアントごとに追跡 | 鍵ライフサイクル管理の健全性 |
DPoP vs. 他のトークンバインディングアプローチ
| 機能 | Bearerトークン | DPoP (RFC 9449) | mTLS (RFC 8705) | トークンバインディング (RFC 8471) |
|---|---|---|---|---|
| トークン盗難防止 | ❌ なし | ✅ 暗号学的バインディング | ✅ TLSバインディング | ✅ TLSバインディング |
| ブラウザサポート | ✅ ユニバーサル | ✅ Web Crypto API | ❌ クライアント証明書UIなし | ❌ ブラウザが断念 |
| モバイルサポート | ✅ ユニバーサル | ✅ プラットフォーム暗号 | ⚠️ 証明書管理が複雑 | ❌ 未実装 |
| 実装の複雑さ | ⭐ シンプル | ⭐⭐ 中程度 | ⭐⭐⭐ 高い | 該当なし(死亡) |
| パフォーマンスオーバーヘッド | なし | リクエストあたり~2ms(署名) | TLSハンドシェイクコスト | 該当なし |
| CDN/プロキシ互換 | ✅ 可 | ✅ 可(アプリ層) | ⚠️ TLS終端の問題 | ❌ TLS終端で壊れる |
| 標準の成熟度 | RFC 6750 (2012) | RFC 9449 (2023) | RFC 8705 (2020) | 中止 |
DPoPがWebとモバイルで勝つ理由はシンプルで、アプリケーション層で動くから。特殊なTLS設定も、クライアント証明書も、ブラウザUI変更も不要。JavaScriptでの暗号処理だけで完結します。
本番セキュリティチェックリスト
クライアントサイド
-
エクスポート不可の鍵を使用する。
crypto.subtle.generateKey()で常にextractable: falseを設定してください。注入されたXSSペイロードを含むどのJavaScriptコードも生の秘密鍵を読めなくなります。 -
鍵を定期的にローテーションする。 ユーザーの再認証やセッション開始時に新しい鍵ペアを生成してください。古いトークンバインディングは自動的に無効になります。
-
IndexedDBを使う、localStorageではなく。
CryptoKeyオブジェクトはIndexedDBにのみ保存可能です。localStorageは文字列しか保存できないため鍵の抽出が必要になり、目的を無にします。 -
クロックスキューを処理する。
iatクレームはサーバーの許容ウィンドウ内でなければなりません。サーバーサイドの検証では±60秒のスキューを許容するのが良いでしょう。
サーバーサイド
-
JTIリプレイキャッシュを維持する。 証明受け入れウィンドウと一致するTTLでRedisなどのストアを使用してください。これがないとタイムウィンドウ内で証明がリプレイされる可能性があります。
-
すべてを検証する。
typ、alg(対称アルゴリズムを拒否)、htm、htu、iat、jti、ath、nonceをすべてチェック。どのステップの検証を省略してもバイパスの可能性が生まれます。 -
高セキュリティコンテキストではノンスを使用する。 金融APIやFAPI 2.0準拠では、サーバー提供ノンスが必須です。一般的なAPIではセキュリティを強化しますが、最初のリクエストで1ラウンドトリップ分のレイテンシが増えます。
-
すべてのレスポンスでノンスを返す。 クライアントが失敗しないとノンスを学べないようにしないでください。すべてのレスポンスに
DPoP-Nonceヘッダーを含めて事前取得できるようにしてください。 -
alg: noneと対称アルゴリズムを拒否する。 DPoP証明は必ず非対称アルゴリズムを使用しなければなりません。HS256を受け入れるとサーバーとクライアントが秘密を共有することになり、DPoPが回避しようとしているまさにその構造になります。
パフォーマンスとコストモデル
DPoPはすべてのリクエストに暗号操作を追加します。実際の影響はこの程度です:
| 操作 | 時間 (P50) | 時間 (P99) | 備考 |
|---|---|---|---|
| 鍵生成 (EC P-256) | 0.5ms | 2ms | セッションごとに1回 |
| 証明の署名 (ECDSA) | 0.8ms | 2.5ms | 毎リクエスト |
| 証明の検証(サーバー) | 0.3ms | 1ms | 毎リクエスト |
| JTIキャッシュ参照 (Redis) | 0.1ms | 0.5ms | 毎リクエスト |
| サムプリント計算 | 0.1ms | 0.3ms | トークン発行時 |
リクエストあたりの総オーバーヘッド:クライアント1.3ms、サーバー0.5ms。参考までに、一般的なDBクエリは5-50msかかります。DPoPのオーバーヘッドはノイズレベルです。
トレードオフは明確です。リクエストあたり~2msの追加レイテンシで、トークン盗難攻撃という攻撃クラス全体を排除できる。
2026年の現実
DPoPは未来の標準ではなく現在の要件なんです。RFC 9449は2023年9月に公開され、金融サービスのFAPI 2.0準拠で必須の2つのsender-constraintメカニズムの1つになりました。Auth0とOktaがDPoPをファーストクラスでサポートし、Microsoft Entra IDは独自のProof-of-Possessionトークンバインディングを提供しています。WebCrypto APIはすべての主要ブラウザで利用可能です。
本当の障壁は慣性です。Bearerトークンを中心に認証システム全体を構築してきたチームが暗号証明の複雑さを追加することを躊躇する。でも実装は複雑じゃないんです。鍵ペア、リクエストごとのJWT、検証ミドルウェアだけ。joseライブラリが重い処理をすべて担ってくれます。上で見たDPoP対応fetchラッパーは50行にも満たない。
Bearerトークンは、XSSがあまり蔓延しておらず、サプライチェーン攻撃が稀で、APIアーキテクチャがもっと単純だった時代のために設計されました。その時代は終わりました。プレーンなBearerとして発行するすべてのトークンは、盗まれればフリクションゼロでリプレイ可能なトークンなんです。
最も価値の高いAPIエンドポイントから始めてください。認証、決済、管理者操作。Bearerトークンと並行してDPoPサポートを追加する。どのクライアントがアップグレードするか監視する。カバレッジが十分になったら、Bearerトークンを完全に廃止する。
トークンは誰が持っているか証明できるべきです。DPoPなら数百行のコードと無視できるレイテンシでそれが実現できます。対応しないコストは、侵害が起きるのを待つことだけです。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう