Google OAuthがDPoPに対応したらしいので検証してみる

〜 壊れていたのは、ドキュメントの方でした 〜

田中博悠 / tanahiro2010

田中博悠 / tanahiro2010

Refresh Token、DBに入れてます

2026年10月15日

Google が「DPoPに対応した」と発表した

Google Developers Blog

Refresh Token を鍵に縛れる

やってみるか

問い

あなたのDBにある Refresh Token

今漏れたら、どうなりますか

前提

DPoP とは

トークンだけ盗まれても、鍵がなければ使えない

RFC 9449

2023年9月に標準化済み

Okta も FusionAuth もとっくに対応している

新しいのは Google が対応したこと だけ

今日やったこと

  • 公式ドキュメント
  • RFC 9449
  • 自分の計測
  • 実際のトークン交換
Node.js  v24.22.0
jose     6.2.12
hono     4.13.13
Apple M4 (arm64)

結論

実装は、ちゃんと動く

壊れていたのは ドキュメントの方

詰まったところは、1つ残らず

ドキュメントとの差分

差分は3種類

  1. RFC と違う(仕様。書いてある)
  2. 書いてあることと、実際が違う
  3. そもそも書いていない

発端

まず「いつ対応したのか」から違った

Wayback Machine で追った

日付
2026-04-15DPoP の記述 ゼロ
2026-05-20ドキュメントに登場
2026-09-04公式 SKILL.md が commit
2026-10-15ブログで発表

5ヶ月

公式ブログの日付 ≠ リリース日

隠していた、という話ではない

そしてこれは、序の口でした

差分①

RFC と違う

ただし、ちゃんと書いてある

Access Token は Bearer のまま

バインドされるのは Refresh Token だけ

ES256、本当に重いのか

µs/opops/s
sign26.238,227
verify50.519,787

Node v24.22.0 / Apple M4 / N=20,000

自分のアプリなら誤差

Gmail の規模なら、効く

jti が乱数じゃない

jti = BASE64URL(SHA-256(authorization_code))

一致した

サンプルの数値は本物だった

Refresh のときは、普通に乱数

同じ jti が フローで別ルール

差分②

書いてあることと、実際が違う

ここからが本題

公式サンプルを、そのまま使うつもりでした

念のため、署名だけ検証した

結果

サンプル署名長
認可コード交換64 BVALID
Token Refresh70 BFAILED

公式サンプルが 壊れている

70バイトの中身

30 44 02 20 ...
ASN.1 DER SEQUENCE
  r: INTEGER len=32
  s: INTEGER len=32

JWS が要求するのは raw R‖S の 64バイト

DER のまま貼られていた

Google 自身が「やるな」と書いている

Node の中でも再現する

crypto.subtle.sign      → 64 bytes (raw)
node:crypto createSign  → 71 bytes (DER)

サンプルは 70 バイトだった

もう1つ、サンプルを開いたら

RFC の例文の丸写しだった

"htu": "https://server.example.com/token"
"iat": 1562262616

2019-07-04。Google のエンドポイントですらない

矛盾

公式ドキュメント同士が、逆のことを書いていた

Nonce は持ち越せるのか

→ workflow をまたぐと「無条件に400」

→ 「余分な往復を避けられる」

HTTP 400 use_dpop_nonce

古い方が正しかった

新しい web-server doc が間違っている

初回 Refresh は 必ず2往復

リトライループは 必須

最適化じゃない

嘘

Adoption Guide にこう書いてある

a nonce value is used only once

再利用された nonce は400で弾く、とも

まず「そもそも検証してるのか」を確かめた

対照

送った nonce
でたらめな乱数400
1文字変えた400

同じ nonce を使い回す

1回目   200
2回目   200
3回目   200
4回目   200
5回目   200
30秒後  200

6回とも通った

jti も同じだった

同じ jti の proof を2回 → 両方200

doc は「invalid_dpop_proof で弾く」と書いている

つまり

proof を そのまま使い回せる

バイト単位で同一の proof

送った proof の SHA-256 とトークンのハッシュを記録して確認

いつまで使えるのか

1時間、ターミナルを開いたまま待った

ここだけは早送りができない

経過と結果

経過
0 〜 3300秒200
3540秒(59分)200
3660秒(61分)400

発行から約1時間、再利用できる

RFC 9449 §11.1

nonce を複数回使ってよい。
ただし jti を追跡して重複を弾いている限り

Google は 条件の方を 満たしていない

差分③

そもそも書いていない

grant が proof より先に検証される

proof のバグが invalid_grant に隠れる

まず DPoP なしで通してから、DPoP を足す

でないと切り分けられない

iat の許容範囲

方向通る弾かれる
過去-3599s-3600s
未来+10s+11s

二分探索で1秒まで。独立2回の実行で一致

360倍の非対称

時計が未来に11秒ずれるだけで、全 proof が弾かれる

NTP が効いてない環境の「なぜか invalid_dpop_proof」

報告

これ、報告した方がいいのでは

Google Bug Hunters に出した

Won't Fix

Infeasible

言われたこと

納得しています

proof を取れる立場なら、Refresh Token も取れる

攻撃能力を 増やしていない

でも、この回答で性質が確定した

セキュリティバグではなく ドキュメントの問題

Google 自身がそう位置づけた

まとめ

差分は3種類だった

  1. RFC と違う — Bearer のまま、jti が乱数じゃない
  2. 書いてあることと違う — サンプル壊れ、矛盾、"used only once"
  3. 書いていない — iat の窓、検証順

実装は動く

詰まるのは ドキュメントとの差分

これから実装する人へ

公式ブログの日付を疑い

公式サンプルを疑い

公式ドキュメント同士を疑った

だから、自分で検証する

おまけ

Google の DPoP、入れるべき?

守備範囲は見かけより狭い

一番重いのは移行コスト

全ユーザーに 再同意

鍵のローテーションも同じ

鍵のバックアップ戦略 = Refresh Token のバックアップ戦略

ありがとうございました

田中博悠 / tanahiro2010

https://blog.tanahiro2010.com

← → 移動 ・ f フルスクリーン ・ o 一覧 ・ p 発表者
記事版を読む →