〜 壊れていたのは、ドキュメントの方でした 〜
田中博悠 / tanahiro2010
2026年10月15日
Google が「DPoPに対応した」と発表した
Google Developers Blog
Refresh Token を鍵に縛れる
やってみるか
問い
あなたのDBにある Refresh Token
今漏れたら、どうなりますか
前提
トークンだけ盗まれても、鍵がなければ使えない
RFC 9449
2023年9月に標準化済み
Okta も FusionAuth もとっくに対応している
新しいのは Google が対応したこと だけ
Node.js v24.22.0
jose 6.2.12
hono 4.13.13
Apple M4 (arm64)結論
実装は、ちゃんと動く
壊れていたのは ドキュメントの方
詰まったところは、1つ残らず
ドキュメントとの差分
発端
まず「いつ対応したのか」から違った
| 日付 | |
|---|---|
| 2026-04-15 | DPoP の記述 ゼロ |
| 2026-05-20 | ドキュメントに登場 |
| 2026-09-04 | 公式 SKILL.md が commit |
| 2026-10-15 | ブログで発表 |
5ヶ月
公式ブログの日付 ≠ リリース日
隠していた、という話ではない
そしてこれは、序の口でした
差分①
RFC と違う
ただし、ちゃんと書いてある
token_type は DPoP が MUSTBearerバインドされるのは Refresh Token だけ
| µs/op | ops/s | |
|---|---|---|
| sign | 26.2 | 38,227 |
| verify | 50.5 | 19,787 |
Node v24.22.0 / Apple M4 / N=20,000
自分のアプリなら誤差
Gmail の規模なら、効く
jti が乱数じゃないjti = BASE64URL(SHA-256(authorization_code))一致した
サンプルの数値は本物だった
Refresh のときは、普通に乱数
同じ jti が フローで別ルール
差分②
書いてあることと、実際が違う
ここからが本題
公式サンプルを、そのまま使うつもりでした
念のため、署名だけ検証した
| サンプル | 署名長 | |
|---|---|---|
| 認可コード交換 | 64 B | VALID |
| Token Refresh | 70 B | FAILED |
公式サンプルが 壊れている
30 44 02 20 ...
ASN.1 DER SEQUENCE
r: INTEGER len=32
s: INTEGER len=32JWS が要求するのは raw R‖S の 64バイト
DER のまま貼られていた
Google 自身が「やるな」と書いている
crypto.subtle.sign → 64 bytes (raw)
node:crypto createSign → 71 bytes (DER)サンプルは 70 バイトだった
もう1つ、サンプルを開いたら
"htu": "https://server.example.com/token"
"iat": 15622626162019-07-04。Google のエンドポイントですらない
矛盾
公式ドキュメント同士が、逆のことを書いていた
→ workflow をまたぐと「無条件に400」
→ 「余分な往復を避けられる」
HTTP 400 use_dpop_nonce
古い方が正しかった
新しい web-server doc が間違っている
初回 Refresh は 必ず2往復
リトライループは 必須
最適化じゃない
嘘
a nonce value is used only once
再利用された nonce は400で弾く、とも
まず「そもそも検証してるのか」を確かめた
| 送った nonce | |
|---|---|
| でたらめな乱数 | 400 |
| 1文字変えた | 400 |
1回目 200
2回目 200
3回目 200
4回目 200
5回目 200
30秒後 2006回とも通った
jti も同じだった
同じ jti の proof を2回 → 両方200
doc は「invalid_dpop_proof で弾く」と書いている
つまり
proof を そのまま使い回せる
jti も nonce も iat も署名も同じ送った proof の SHA-256 とトークンのハッシュを記録して確認
いつまで使えるのか
1時間、ターミナルを開いたまま待った
ここだけは早送りができない
| 経過 | |
|---|---|
| 0 〜 3300秒 | 200 |
| 3540秒(59分) | 200 |
| 3660秒(61分) | 400 |
発行から約1時間、再利用できる
nonce を複数回使ってよい。
ただしjtiを追跡して重複を弾いている限り
Google は 条件の方を 満たしていない
差分③
そもそも書いていない
invalid_grantDPoP-Nonce は一度も返らないproof のバグが invalid_grant に隠れる
まず DPoP なしで通してから、DPoP を足す
でないと切り分けられない
iat の許容範囲| 方向 | 通る | 弾かれる |
|---|---|---|
| 過去 | -3599s | -3600s |
| 未来 | +10s | +11s |
二分探索で1秒まで。独立2回の実行で一致
360倍の非対称
時計が未来に11秒ずれるだけで、全 proof が弾かれる
NTP が効いてない環境の「なぜか invalid_dpop_proof」
報告
これ、報告した方がいいのでは
Won't Fix
Infeasible
納得しています
proof を取れる立場なら、Refresh Token も取れる
攻撃能力を 増やしていない
でも、この回答で性質が確定した
セキュリティバグではなく ドキュメントの問題
Google 自身がそう位置づけた
まとめ
jti が乱数じゃないiat の窓、検証順実装は動く
詰まるのは ドキュメントとの差分
jti はフローで別ルールhtm/htu 不一致は invalid_requestiat は過去3600秒・未来10秒extractable: false にできない公式ブログの日付を疑い
公式サンプルを疑い
公式ドキュメント同士を疑った
だから、自分で検証する
おまけ
Google の DPoP、入れるべき?
一番重いのは移行コスト
全ユーザーに 再同意
鍵のローテーションも同じ
鍵のバックアップ戦略 = Refresh Token のバックアップ戦略
田中博悠 / tanahiro2010
https://blog.tanahiro2010.com