google/skills is packaging work. It turns Google product guidance into installable skills, mostly basics, recipes, and Well-Architected topics. My take: spec first automation starts by making documented product knowledge portable. #SystemsLiteracy
Your Agent Changed Last Night and Nobody Deployed Anything: Skill Drift on Google Cloud Skill Registry
The incident that never shows up in your deploy log
Here is a scenario that's easy to create on Gemini Enterprise Agent Platform today.
You build a Managed Agent. You attach a skill from Skill Registry called invoice-triage. It works great. You ship it.
Two weeks later, someone on another team updates invoice-triage to handle a new vendor format. They test it with their own agent. It works for them.
That same afternoon, your production agent starts misrouting invoices.
Nobody touched your agent. No pull request, no deploy, no change ticket. Your CI pipeline is green. Your agent config in Git is identical to last week.
What changed is the skill underneath it. I call this skill drift, and in this post I will show you exactly why it happens, how to measure a second hidden problem that comes with it (the context tax of mounting many skills), and a small lockfile pattern that fixes both.
Everything here is based on the public Managed Agents API and Skill Registry docs. Both are Preview, so treat the code as a starting point and test it in a sandbox project first.
Quick refresher: how skills reach your agent
A skill is a folder with a SKILL.md file plus optional scripts/, references/, and assets/. You upload it to Skill Registry as a zipped payload, and the registry keeps a revision history for every update.
When you create or update a Managed Agent, you mount skills into the agent's sandbox through base_environment.sources. The docs give you three ways to point at the registry:
| Source path | What the agent gets |
|---|---|
| projects/P/locations/L/skills/SKILL_ID | The default version, whatever it is right now |
| projects/P/locations/L/skills/SKILL_ID/skill_versions/VERSION | One exact version, frozen |
| projects/P/locations/L/skills | Up to 100 skills from that project and location |
The first and third options are the ones people copy from quickstarts. Both are floating references. That is the root of the problem.
Problem 1: floating references are :latest tags for agent behavior
We learned this lesson years ago with container images. Deploying myapp:latest to production means your production state is defined by whoever pushed last, not by what you reviewed.
A skill path without a version is the same thing, except worse in one way: a skill is mostly natural language instructions. A tiny wording change in SKILL.md can change which tool the model calls, what it refuses, or how it formats output. There is no compiler error. There is no failing unit test unless you wrote evals for it.
A few details from the docs make this sharper:
- UpdateSkill can replace the display name, description, and the whole zipped filesystem in one call.
- The skill ID becomes the folder name inside the agent sandbox, so the agent keeps "seeing" the same skill name even after the content changed.
- Skill Registry inherits project level IAM. Anyone with write access in the project can update any skill in it.
So the blast radius of one skill update is every agent that mounts that skill without a version.
Problem 2: the context tax of "mount everything"
The third path above is tempting. One line, and your agent has every skill in the project.
But think about how skills work. In the Agent Skills pattern, the agent sees each skill's name and description up front so it can decide which one to open. The full SKILL.md body only loads when the skill is picked. That is called progressive disclosure, and it keeps things cheap when you have a handful of skills.
With up to 100 skills mounted, those names and descriptions add up. That text rides along on every turn, whether or not any of those skills are relevant.
There is also a quality cost. More skills with overlapping descriptions means more chances for the model to pick the wrong one. Two skills named invoice-triage and invoice-review with similar descriptions is a routing bug waiting to happen.
You cannot manage what you do not measure, so let's measure it.
Measuring the skill metadata budget
This script lists every skill in a project and location, joins the names and descriptions the way an agent would roughly see them, and counts tokens with the Count Tokens API.
# skill_context_tax.py
import os
import requests
import google.auth
from google.auth.transport.requests import Request
from google import genai
PROJECT = os.environ["GCP_PROJECT_ID"]
LOCATION = os.environ.get("SKILL_LOCATION", "us-central1")
MODEL = os.environ["MODEL_ID"] # the Gemini model your agent uses
BASE = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT}/locations/{LOCATION}"
def authed_session():
creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
creds.refresh(Request())
s = requests.Session()
s.headers["Authorization"] = f"Bearer {creds.token}"
return s
def list_skills(s):
skills, token = [], None
while True:
params = {"pageToken": token} if token else {}
r = s.get(f"{BASE}/skills", params=params)
r.raise_for_status()
body = r.json()
skills.extend(body.get("skills", []))
token = body.get("nextPageToken")
if not token:
return skills
def main():
s = authed_session()
skills = list_skills(s)
catalog = "\n".join(
f"- {sk.get('displayName', '')}: {sk.get('description', '')}" for sk in skills
)
client = genai.Client(vertexai=True, project=PROJECT, location="global")
total = client.models.count_tokens(model=MODEL, contents=catalog).total_tokens
print(f"Skills in {LOCATION}: {len(skills)}")
print(f"Metadata tokens per turn (floor estimate): {total}")
if skills:
print(f"Average per skill: {total / len(skills):.1f}")
if __name__ == "__main__":
main()
Two honest caveats about this number:
1. It is a floor. The base agent may wrap each skill in extra formatting, file paths, or instructions that you cannot see.
2. It uses the registry description. If the description in your SKILL.md frontmatter is longer, the real cost is higher. Keep the two in sync.
To turn it into a monthly number, multiply by your turns per month and your model's input price. Here is the math with made up example numbers, just to show the shape:
80 skills x 70 tokens = 5,600 tokens of skill metadata per turn
5,600 x 2,000,000 turns/month = 11.2 billion input tokens per month
Plug in your own skill count, turn volume, and the current price from the pricing page. If the result surprises you, that is the point of measuring.
The fix: a skill lockfile
The pattern is the same one we use for package managers. Floating references are fine for development. Production gets exact, reviewed versions recorded in a file that lives in Git.
The flow looks like this:
author updates skill -> registry creates new revision
-> staging agent (floating) picks it up
-> evals run against staging
-> pass? update skills.lock.json in a PR
-> merge -> CI pins prod agent to locked versions
Step 1: generate and check the lockfile
# skill_lock.py
import json
import os
import sys
import requests
import google.auth
from google.auth.transport.requests import Request
PROJECT = os.environ["GCP_PROJECT_ID"]
LOCATION = os.environ.get("SKILL_LOCATION", "us-central1")
BASE = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT}/locations/{LOCATION}"
LOCKFILE = "skills.lock.json"
def authed_session():
creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
creds.refresh(Request())
s = requests.Session()
s.headers["Authorization"] = f"Bearer {creds.token}"
return s
def latest_revision(s, skill_id):
revs, token = [], None
while True:
params = {"pageToken": token} if token else {}
r = s.get(f"{BASE}/skills/{skill_id}/revisions", params=params)
r.raise_for_status()
body = r.json()
revs.extend(body.get("skillRevisions", []))
token = body.get("nextPageToken")
if not token:
break
if not revs:
return None
revs.sort(key=lambda rev: rev["createTime"])
newest = revs[-1]
return {"revision": newest["name"].split("/")[-1], "created": newest["createTime"]}
def lock(skill_ids):
s = authed_session()
data = {"project": PROJECT, "location": LOCATION, "skills": {}}
for sid in skill_ids:
data["skills"][sid] = latest_revision(s, sid)
with open(LOCKFILE, "w") as f:
json.dump(data, f, indent=2)
print(f"Wrote {LOCKFILE} with {len(skill_ids)} skills")
def check():
s = authed_session()
with open(LOCKFILE) as f:
data = json.load(f)
drift = []
for sid, locked in data["skills"].items():
current = latest_revision(s, sid)
if current is None or current["revision"] != locked["revision"]:
drift.append((sid, locked["revision"], current and current["revision"]))
for sid, old, new in drift:
print(f"DRIFT {sid}: locked {old}, registry now has {new}")
sys.exit(1 if drift else 0)
if __name__ == "__main__":
if sys.argv[1] == "lock":
lock(sys.argv[2:])
elif sys.argv[1] == "check":
check()
Usage:
python skill_lock.py lock invoice-triage vendor-lookup refund-policy
python skill_lock.py check # exits 1 if any skill moved
The check command is your drift alarm. It does not block anyone from updating a skill. It just tells you, loudly, that the registry no longer matches what production was approved with.
Step 2: pin the production agent to the lockfile
The agent update uses PATCH with update_mask=base_environment. There is no update method in the Python SDK for agents yet, so this goes through REST.
One gotcha worth calling out: the docs examples resend the whole base_environment object, including the network allowlist. Treat this update as a full replace, not a merge. If you send only sources, plan for the possibility that other environment settings go back to defaults. Network access is off by default, so a lost allowlist can break an agent that calls external APIs.
# pin_agent.py
import json
import os
import requests
import google.auth
from google.auth.transport.requests import Request
PROJECT = os.environ["GCP_PROJECT_ID"]
AGENT_ID = os.environ["AGENT_ID"]
# The agent docs call this segment "skill_versions", while the registry
# returns revision names under "revisions". Confirm which one your project
# accepts, then set it here.
PIN_SEGMENT = os.environ.get("PIN_SEGMENT", "skill_versions")
ALLOWLIST = json.loads(os.environ.get("NETWORK_ALLOWLIST", '[{"domain": "api.example.com"}]'))
AGENT_URL = (
f"https://aiplatform.googleapis.com/v1beta1/projects/{PROJECT}"
f"/locations/global/agents/{AGENT_ID}?update_mask=base_environment"
)
def main():
with open("skills.lock.json") as f:
lock = json.load(f)
sources = []
for sid, info in lock["skills"].items():
path = (
f"projects/{lock['project']}/locations/{lock['location']}"
f"/skills/{sid}/{PIN_SEGMENT}/{info['revision']}"
)
sources.append({"type": "skill_registry", "source": path, "target": "/.agent/skills"})
body = {
"name": AGENT_ID,
"base_environment": {
"type": "remote",
"sources": sources,
"network": {"allowlist": ALLOWLIST},
},
}
creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
creds.refresh(Request())
r = requests.patch(
AGENT_URL,
headers={"Authorization": f"Bearer {creds.token}", "Content-Type": "application/json"},
json=body,
)
r.raise_for_status()
print("Update started:", r.json().get("name"))
if __name__ == "__main__":
main()
The update is a long running operation, so poll the returned operation name before you call the rollout done.
Step 3: run the drift check on a schedule
Any scheduler works. Here is a minimal GitHub Actions version that runs every morning and fails loudly on drift:
name: skill-drift-check
on:
schedule:
- cron: "0 12 * * *"
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.DRIFT_SA }}
- run: pip install requests google-auth
- run: python skill_lock.py check
env:
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
SKILL_LOCATION: us-central1
The service account only needs read access, so roles/aiplatform.viewer is enough for this job.
A few more things I noticed while building this
Skill IDs are a namespace you cannot get back quickly. Skill IDs are immutable, and after a delete the ID stays reserved for a while (the docs mention 24 hours in one place and permanent reservation in another, so plan as if it is long). Pick names carefully, and do not use delete plus recreate as a "rollback" strategy. Pinning a previous version is the rollback.
Regions do not line up one to one. Managed Agents only support the global location, while Skill Registry lives in specific regions (us-central1, europe-west4, us-east5 at the time of writing). Your mount path includes the skill's region, so write that region into your lockfile instead of assuming it.
Cloud Storage mounts skip validation. You can also mount skills straight from a bucket. The docs say the backend does not validate folder content in that case. That makes the registry path the safer choice for production, because uploads go through payload checks and you get revision history.
Write descriptions for routing, not marketing. Since descriptions are both your token cost and the model's routing signal, short and specific beats long and friendly. "Classifies AP invoices by vendor and urgency. Not for refunds." does more work than a paragraph.
Limitations and caveats
- Skill Registry and Managed Agents are Pre-GA. APIs, paths, and field names can change, and the docs say not to use confidential data with the Managed Agents preview.
- At the time of writing, Skill Registry does not support VPC Service Controls, CMEK, or HIPAA. Check the compliance table before you design around it.
- The docs use two different names for a pinned skill path (skill_versions on the agent side, revisions on the registry side). My script makes this configurable because I could not confirm which is final. Verify in your own project.
- The token numbers from the context tax script are a lower bound, not an exact bill. The base agent may add text you cannot see.
- A lockfile tells you what changed. It does not tell you whether the change is good. Pair it with evals on a staging agent before promoting.
Wrapping up
Skills make agents easier to build and share across teams. That same sharing means a skill update is a production change for every agent that mounts it without a version.
Three habits fix most of it: pin versions in production, measure what "mount everything" costs you, and run a daily drift check against a lockfile in Git. None of this needs new infrastructure. It is the same discipline we already apply to container images and package managers, pointed at a new kind of artifact.
If you try this pattern, I would love to hear what your skill metadata token count came out to.
References
- Create and manage agents (attach skills, pinning, ListSkills mount): docs.cloud.google.com
- Create and manage skills in Skill Registry: docs.cloud.google.com
- Google Skills repository (SKILL.md examples): google/skills
- Agent Skills format conventions: agentskills.io
8 reactions
Using npx skills add google/skills, you can pull Agent Skills to set up Google Cloud environments and AI agents.
google/skills
@theGOATmeeh Check out our skill!
github.com/google/skills/tree/main/skills/develop…
1 like · 29 views
@tendon0 こちらですかね?
google/skills
1 like · 29 views
Tired of writing custom tools for every Google Cloud service? Agent Skills gives your AI agents pre-built GCP workflows.
google/skills
Google OAuthがDPoPに対応したらしいので検証してみたら...なんか違った
実装は、ちゃんと動きました。壊れていたのはドキュメントの方です。
この記事の結論
Google OAuth の Refresh Token を DPoP で鍵にバインドする仕組みを、公式ドキュメント・RFC 9449・自分の計測・実際のトークン交換の 4 方向から検証しました。実装はまともに動きます。詰まったところは、1 つ残らず「ドキュメントとの差分」でした。
差分は 3 種類に分かれます。
RFC と違う — Access Token は Bearer のまま、jti が乱数ではない。設計判断で、ドキュメントには書いてあります
書いてあることと、実際が違う — 公式サンプルの署名が壊れている、ドキュメント同士が矛盾している、"a nonce value is used only once" が嘘
そもそも書いていない — iat の許容範囲、grant と proof の検証順。実測するしかありませんでした
実測した環境です。
項目
値
検証日
2026-10-19
Node.js
v24.22.0
jose
6.2.12
hono
4.13.13
マシン
Apple M4 (arm64)
実装はこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
Google の DPoP 実装そのものは、おかしくありません。 設計判断にはちゃんと理由があり、性能を理由にした主張も測ってみれば妥当でした。それでも実装者が詰まるのは、書いてあることが実際と違ったり、肝心なことが書いていなかったりするからです。最後に見つけた "used only once" の件は Google に報告しましたが、Won't Fix で返ってきました。つまりそれも、仕様の問題ではなく記述の問題だったということです。
この記事の対象は、Google API 連携で Refresh Token をサーバーに保存している開発者です。
1. DPoP とは何か
DPoP(Demonstrating Proof-of-Possession)は RFC 9449 で標準化された OAuth 2.0 のセキュリティプロファイルです。2023 年 9 月に Proposed Standard になっていて、Okta や FusionAuth はずっと前から対応しています。新しいのは Google が対応したことだけで、DPoP 自体は新登場ではありません。
通常の Bearer Token は、Google のブログの表現を借りると "finders-keepers model" です。拾った人が使えてしまう。DPoP はこれを送信者拘束(sender-constraining)で防ぎます。
クライアントが鍵ペアを生成し、秘密鍵を自分だけが持つ
トークンエンドポイントへのリクエストごとに、秘密鍵で署名した JWT(DPoP proof)を添える
サーバーはトークンを公開鍵に紐づけて発行する
検証は Node.js + TypeScript のサーバー(Google OAuth の Confidential Client)で行いました。JWT ライブラリは jose 6.2.12 で、依存ゼロで ES256 署名と JWK エクスポートが揃います。専用のテストクライアントとテストアカウントを使い、本番ユーザーの grant には触れていません。
2. まず「いつ対応したのか」が違った
発表は 2026-10-15 の Google Developers Blog です。ところが Wayback Machine で公式ドキュメントの改版を追うと、話が合いません。
日付
出来事
2026-04-15
Best Practices に DPoP の記述はゼロ
2026-05-20
Best Practices に "Sender-constrain tokens with DPoP" が登場
2026-07-31
DPoP Adoption Guide の最終更新
2026-09-04
google/skills に公式の DPoP SKILL.md が commit
2026-10-15
Developers Blog で発表
2026-10-16
web-server doc が DPoP 対応で更新
ドキュメント上は、少なくとも 5 月には対応が入っていたことになります。発表はその約 5 ヶ月後です。隠していたという話ではなく、公式ブログの日付は機能のリリース日とは限らない、というだけの話です。
そしてこれは序の口でした。確かめるほど差分が出てきて、しかもきれいに 3 種類に分かれます。
3. 差分①:RFC と違う(仕様。ちゃんと書いてある)
RFC 9449 はオプションの判断を各認可サーバーに委ねています。Google の選択は RFC の一般仕様とズレますが、これは「書いてある」類の差分です。RFC だけ読んで実装すると、ここで詰まります。
Access Token は Bearer のまま
RFC 9449 §5 は token_type に DPoP を MUST としていますが、web-server doc は「token_type は DPoP を使っても常に Bearer」と明言しています。バインドされるのは Refresh Token だけです。
ただしこれは RFC 違反ではありません。RFC 側に逃げ道があって、public client が Refresh Token も受け取る場合に「Refresh Token だけをバインドする」ことを明示的に許容しています。
理由はブログによると性能で、高 QPS の API で毎回非対称署名を検証するコストを避けたい、とのことです。これは測ってみました。Node v24.22.0 / Apple M4 で 20,000 回のループです。
操作
µs/op
ops/s
ES256 sign
26.2
38,227
ES256 verify
50.5
19,787
検証は署名の約 2 倍、1 コアあたり毎秒 2 万回程度でした。自分のアプリの規模なら誤差ですが、Gmail や Drive の規模なら効きます。 Google の言い分は妥当だと思います。
認可コード交換の jti が乱数ではない
RFC 9449 §4.2 は jti に「96 bit 以上の擬似乱数か UUIDv4」を要求します。ところが Google は、認可コード交換のときだけ決定的な値を使います。
the value of jti is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti = BASE64URL(SHA-256(authorization_code))
公式サンプルに載っている認可コードと jti で、実際に計算して確かめました。
import hashlib, base64
code = "4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w"
print(base64.urlsafe_b64encode(hashlib.sha256(code.encode()).digest()).rstrip(b"=").decode())
-> o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (サンプルの jti と一致)
一致しました。Refresh のときは逆に普通のランダム値(192 bit)を使います。同じ jti クレームが、フローによって別のルールで決まるので、片方だけ実装すると必ず詰まります。
なぜこうなっているのか。RFC が定める「認可コードを鍵に縛る」方法は §10 の dpop_jkt という認可リクエストパラメータですが、Google の認可リクエストに dpop_jkt はありません。代わりに既存の jti クレームを流用しています。認可コードは一度しか使えないので、決定的な値でも実質的に一意になり、目的は達成できます。RFC の OPTIONAL な機能を実装する代わりに、手持ちのクレームで代替した、と読めます。
Confidential Client なのにバインドする
RFC 9449 §5 はこう書いています。
Refresh tokens issued to confidential clients ... are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism.
ところが Google の Adoption Guide のサンプルは client_secret を送る Confidential Client です。一方で Best Practices が DPoP を強く勧めるのは SPA やインストール型アプリといった public client で、その SPA には「BFF(= Confidential Client)を立てろ」と言います。public client を守りたいのに、実装は Confidential Client になるというねじれです。
RFC は「鍵に縛るより client 認証の方が柔軟(鍵を変えても Refresh Token を失効させずに済む)」とまで書いていて、Google の構成はその注意点をあえて踏みにいっています。
4. 差分②:書いてあることと、実際が違う
ここからが本題です。
公式サンプルの proof は、署名が壊れている
Adoption Guide には proof のサンプルが 2 つ載っています。そのまま拝借するつもりでしたが、念のため動かす前に署名だけ検証しました。そこで止まりました。
サンプル
署名長
検証結果
認可コード交換
64 B
SIGNATURE VALID
Token Refresh
70 B
SIGNATURE FAILED
70 バイトの中身を ASN.1 としてパースすると、きれいに通りました。
hex head: 30 44 02 20 ...
ASN.1 DER SEQUENCE? True | declared len: 68
r: tag=0x02 (INTEGER) len=32
s: tag=0x02 (INTEGER) len=32
JWS が要求するのは raw な R || S(P-256 なら 64 バイト、IEEE P1363 形式)ですが、サンプルは ASN.1 DER のまま貼られています。これは Google 自身が web-server doc で「やるな」と書いていることそのものです。
Note that JWS requires the signature to be in a raw R | S concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format.
この罠は Node の中でも再現できます。同じ ES256 署名でも、API によって出力形式が違います。
crypto.subtle.sign -> 64 bytes, first byte 0xed (raw P1363)
node:crypto createSign -> 71 bytes, first byte 0x30 (ASN.1 DER)
node:crypto 側は DER で、しかも 71 バイトです。サンプルは 70 バイトでした。DER の長さは leading zero 次第で 69〜72 バイトに揺れるので、length === 70 のような長さチェックで DER を判定してはいけません。
もう 1 つ。web-server doc のサンプル proof をデコードすると、RFC 9449 の例文の丸写しでした。htu が server.example.com のエンドポイントではない)、iat は 1562262616(2019-07-04)で、jti も同じページの 3 段落上に書いてある自分のルールを破っています。そのままコピーすると htu 不一致で弾かれます。
Nonce の持ち越し可否で、ドキュメント同士が矛盾している
認可コード交換で受け取った DPoP-Nonce を、初回の Refresh にそのまま使えるか。ここで新旧 2 つのドキュメントが逆のことを書いています。
Adoption Guide(2026-07-31):workflow をまたぐと「無条件に 400 で拒否する」
web-server doc(2026-10-16):この nonce を使えば「余分な往復を避けられる」
実際に投げたら HTTP 400 use_dpop_nonce でした。古い Adoption Guide の方が正しく、新しい web-server doc が間違っています。 つまり初回 Refresh は必ず 400 → リトライで 2 往復になります。use_dpop_nonce のリトライループは最適化ではなく必須で、省くと認可直後の 1 回目が必ず失敗します。
エラーコードの説明が違う
web-server doc は「htm/htu 不一致、iat 期限切れ、再利用、署名不正はすべて invalid_dpop_proof」と書いていますが、実測は違いました。
doc の主張
実測
htu 不一致 → invalid_dpop_proof
invalid_request
❌
htm 不一致 → invalid_dpop_proof
invalid_request
❌
iat 期限切れ → invalid_dpop_proof
invalid_dpop_proof
✅
署名不正 → invalid_dpop_proof
invalid_dpop_proof
✅
実際の切り分けはこうなっていて、こちらの方がドキュメントの記述より一貫しています。
状況
error
proof が無い / htm・htu が噛み合わない
invalid_request
署名が不正 / iat が範囲外
invalid_dpop_proof
nonce が無い・期限切れ・workflow 跨ぎ
use_dpop_nonce
grant 自体が不正
invalid_grant
extractable: false は、Node には当てはまらない
Google 公式の SKILL.md は、秘密鍵の扱いを「CRITICAL SECURITY GUARDRAIL」としてこう要求します。
The private key MUST be configured as non-extractable (extractable: false).
ところが Node.js でこれを字義通り守ると、鍵を永続化できなくなります。
exportKey('jwk') -> InvalidAccessError: key is not extractable
exportKey('pkcs8') -> InvalidAccessError: key is not extractable
非 extractable な鍵はプロセスの外に出せません。サーバーが再起動した瞬間、その鍵にバインドされた全ユーザーの Refresh Token が使用不能になり、復旧手段は再同意しかありません。
さらに、Node.js の WebCrypto には TPM も Secure Enclave もありません。非 extractable 鍵はただのプロセスメモリ上の鍵で、SKILL.md が言う「hardware cryptographic boundary」は Node には存在しません。この文言はブラウザやモバイル向けで、Adoption Guide 自身が前提にしている Node の Confidential Client には当てはまらないのです。
現実的には extractable: true で生成し、書き出した JWK を暗号化や KMS で保護して保存することになります。動きますが、その瞬間に秘密鍵成分が DB に乗ります。
"a nonce value is used only once" が、嘘だった
Adoption Guide はこう書いています。
Note that a nonce value is used only once and a missing, invalid, expired, or reused nonce value is rejected with an HTTP 400 response.
まず「そもそも nonce は検証されているのか」の対照を取りました。でたらめな nonce と 1 文字変えた nonce は、どちらも 400 use_dpop_nonce で弾かれます。検証はされています。
そのうえで、同じ nonce を続けて使いました。
1 回目(同じ nonce) 200
2 回目(同じ nonce) 200
3 回目(同じ nonce) 200
4 回目(同じ nonce) 200
5 回目(同じ nonce) 200
30 秒後(同じ nonce) 200
6 回とも通りました。 検証はされるが、消費はされません。
同じことが jti でも起きます。web-server doc は「再利用された jti は invalid_dpop_proof」と書いていますが、同じ jti を持つ proof を 2 回投げても両方 200 でした。jti は記録されていません。
この 2 つが重なると、proof をバイト単位でそのまま再利用できます。 jti・nonce・iat・署名まで完全に同一の proof を投げ直すと、繰り返し 200 が返り、そのたびに新しい Access Token が発行されました(送った proof の SHA-256 と、返ってきたトークンのハッシュを記録しています。同じ proof から毎回違うトークンが出る=キャッシュではなく本当に再発行されている、ということです)。
いつまで再利用できるのか。1 本の proof を作って、現実に盗まれた proof と同じ条件(iat も nonce も一緒に古くなる)で、時間をおいて投げ直しました。ここだけは早送りができないので、ターミナルを開いたまま 1 時間待ちました。
経過(proof 作成から)
HTTP
結果
0 〜 2700 秒(0〜45 分)
200
再利用成立
3300 秒(55 分)
200
再利用成立
3540 秒(59 分)
200
再利用成立
3660 秒(61 分)
400
invalid_dpop_proof
59 分では通り、61 分で invalid_dpop_proof(use_dpop_nonce ではない)で落ちました。nonce は 59 分時点でもまだ有効だったので、先に来たのは iat の窓です。捕捉した proof は、発行からおよそ 1 時間、何度でも再利用できます。
ここで RFC 9449 §11.1 を読み直すと、まさにこの条件が書いてあります。
Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... As long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay.
nonce の使い回しを許していいのは、jti を追跡している場合に限る。Google は jti を追跡しないまま、nonce 再利用だけを許しています。RFC が条件付きで認めた許可の、条件の方を満たしていない状態です。
5. 差分③:そもそも書いていない
実装していて必要になったのに、どこにも書かれていなかったものです。実測するしかありませんでした。
grant は proof より先に検証される
存在しない Refresh Token を送りつつ、proof の方だけを 8 通りに壊してみました。DER 署名の proof でも、typ を壊しても、not-a-jwt を送っても、全部 invalid_grant で、DPoP-Nonce は一度も返りませんでした。
ここから実装上の教訓が出ます。デバッグ中に proof のバグが invalid_grant に隠れるのです。Refresh Token が期限切れだと、proof がどれだけ壊れていても invalid_grant しか返りません。「proof を直したのに直らない」の原因がここにあることがあります。
実装の順序としては「まず DPoP なしで Refresh が通ることを確認してから DPoP を足す」が正解になります。どちらが壊れているのか切り分けられるからです。
iat の許容範囲
Google は「期限切れの iat は弾く」としか書いておらず、秒数を公開していません。RFC 9449 §4.3 も実装依存としています。二分探索で 1 秒まで詰めました(独立した 2 回の実行で一致)。
方向
通る最遠
弾かれる最近
値
過去
-3599 s
-3600 s
ちょうど 3600 秒
未来
+10 s
+11 s
ちょうど 10 秒
過去は 3600 秒、未来は 10 秒。360 倍の非対称です。 過去側が緩いのは proof が届くまでの遅延を見込むため、未来側が厳しいのは未来の iat が「proof の作り置き」を意味するからで、RFC 9449 §11.2 "DPoP Proof Pre-generation" が警戒しているのがこれです。なお §4 で見た「proof を約 1 時間再利用できる」の窓を決めていたのは、この過去側の 3600 秒でした。別々に測った 2 つの数字が同じ値を指したことになります。
実装側の教訓としては、サーバーの時計が未来に 11 秒ずれているだけで全 proof が弾かれます。NTP が効いていない環境で「なぜか invalid_dpop_proof」が出るときは、ここを疑う価値があります。
その他、書かれていなかったもの
成功レスポンスの challenge フィールド。 Adoption Guide のサンプルには出てきますが、本文に説明がありません。値は DPoP-Nonce ヘッダーと同一でした
discovery に DPoP の宣言がない。 RFC 9449 §5.1 は dpop_signing_alg_values_supported という authorization server metadata を定義していますが、accounts.google.com/.well-known/openid-configuration を引いても dpop を含むキーは 1 つもありません。discovery ベースで feature detection する標準準拠のクライアントは、Google の DPoP 対応を検出できません
RSA を使う場合の alg 名。 Adoption Guide は RSA も可としますが「計算コストが高いので非推奨」とだけ書かれていて、RS256 なのか PS256 なのかは書かれていません。discovery にも無いので、投げて試すしかありません
6. 報告したら Won't Fix だった
§4 の最後の件(jti 未追跡 + nonce 再利用 + proof の再利用)は、ドキュメントとの食い違いであると同時に RFC の hardening 条件も満たしていないので、Google の Bug Hunters に報告しました。再現用の生ログ(秘密は不可逆なハッシュに置き換えたもの)を添えています。
結果は Won't Fix(Infeasible) でした。要旨はこうです。
アカウント乗っ取り・権限昇格・認証バイパスが示されていない
報告者自身が「実用上の影響は限定的」と認めている
「他ユーザーの機微データの機密性・完全性を実質的に損なう」という VRP の基準を越えていない
この判断には納得しています。proof を観測できる立場(AiTM、TLS 終端プロキシ、ログ)なら、同じリクエストボディに入っている refresh_token と client_secret も一緒に取れます。しかも §5 のとおり proof 単体では invalid_grant で弾かれるので、再利用には有効な refresh_token が必須です。再利用できる窓があっても、攻撃者の能力を増やしていません。
逆に言うと、この回答で finding の性質が確定しました。セキュリティバグではなく、ドキュメントと実装の食い違いである、と Google 自身が位置づけたということです。この記事の主張そのものです。
それでも書く価値はあると思っています。"a nonce value is used only once" や「再利用された jti は弾く」は、読んで実装すると現実と違うので、その通りに作れば要らない排他制御を書いたり、効いていない前提に寄りかかったりします。報告して「追跡しない」と返ってきた結末まで含めて、公式の記述を鵜呑みにしない材料になるはずです。
7. で、入れるべきなのか
DPoP バインドが効くのは、Refresh Token を長期保存していて、その保存先が漏れるリスクを下げたいケースです。DB ダンプやログ流出で Refresh Token だけが漏れても、鍵がなければ再利用されません。これは確かな価値です。
ただし守備範囲は見かけより狭いです。Access Token は Bearer のままなので有効期限内は使われますし、ブラウザのセッション Cookie は対象外だとブログが明言しています(その領域は DBSC が担当する、という整理です)。そして §4 のとおり Node では秘密鍵を結局どこかに保存することになるので、「鍵は外に出ない」という前提も一段弱くなります。
導入コストで一番重いのは移行です。すでに Refresh Token を持っているユーザーを DPoP バインドに切り替えるには、既存の grant を revoke するか prompt=consent で再発行させるしかありません。稼働中のサービスでは全ユーザーに再同意を踏ませることになります。鍵をローテーションしたい場合も同じで、新しい鍵にバインドされた Refresh Token を得るには再認可が必要です。つまり鍵のバックアップ戦略が、そのまま Refresh Token のバックアップ戦略になります。
8. まとめ
Google の DPoP 実装は、動きます。仕様の選択にも理由があり、Bearer のままにした性能上の判断は測ってみれば妥当でした。
詰まったのは全部、ドキュメントとの差分でした。RFC と違うところ(§3)、書いてあることと実際が違うところ(§4)、そもそも書いていないところ(§5)。一番派手だった "used only once" は報告しましたが、Won't Fix で返ってきて、やはり記述の問題だったことがはっきりしました(§6)。
ドキュメントを読むだけでは出てこない点を置いておきます。
認可コード交換の jti は BASE64URL(SHA-256(code))、Refresh は乱数。フローで別ルール
初回 Refresh は必ず use_dpop_nonce で 1 回落ちる。リトライループは必須
htm/htu の不一致は invalid_dpop_proof ではなく invalid_request
iat は過去 3600 秒・未来 10 秒。時計が未来にずれると全滅する
Node では extractable: false にできない。鍵の保存方法を先に決める
まず DPoP なしで Refresh を通してから DPoP を足す。でないと invalid_grant に隠れる
参考
DPoP Adoption Guide — Google の DPoP 実装ガイド。proof の構成と Nonce の扱い
Using OAuth 2.0 for Web Server Applications — トークンエンドポイントのパラメータとエラー、prompt=consent の注記
OAuth 2.0 Best Practices — sender-constraining の推奨対象
Announcing DPoP Support for Google OAuth — 発表ブログ。性能トレードオフと脅威モデル
RFC 9449 — DPoP の仕様。§5 token_type、§10 dpop_jkt、§11 セキュリティ考慮
google/skills — dpop-adoption — 公式の実装スキル。非 extractable 鍵と署名形式
1 like
Google Just Put 166 Claude Skills on GitHub for Free
Free 7-day Claude course when you subscribe → dub.sh
Google just put 166 of its own Claude skills on GitHub for free.
It's called Google Skills: the official agent skills for Google's own products. BigQuery, Firebase, Cloud Run, the Gemini API, Google Ads, Analytics and more.
These aren't prompts you paste in and hope. They're skills you drop into Claude, so it already knows how Google's tools work before you ask. The Google Skill Finder loads only the skill a task needs, so it costs almost nothing until a lookup happens.
Over 21,000 developers have starred it. One command installs it in Claude Code or Codex:
npx skills add google/skills
Google Skills: google/skills
Comment "GOOGLE" and I'll send you the install command.
#claudecode #claude #google #aiagents #shorts
5 likes · 518 views
I Tested Google's DPoP Implementation. The Documentation Told a Different Story.
> The implementation worked. It was the documentation that let me down.
TL;DR
I investigated Google's mechanism for binding OAuth refresh tokens to a cryptographic key using DPoP from four angles: the official documentation, RFC 9449, my own measurements, and actual token exchanges. The implementation itself worked as intended. Every obstacle I encountered came down to a difference between the documentation and observed behavior.
The differences fell into three categories:
- Different from the RFC: Access tokens remain Bearer tokens, and jti isn't random in the authorization-code flow. These are design choices documented by Google.
- Different from what the documentation claims: An official example contains an incorrectly encoded signature, different documentation pages contradict one another, and the claim that "a nonce value is used only once" doesn't match observed behavior.
- Not documented at all: The accepted iat window and the order in which the grant and DPoP proof are validated. I had to measure these myself.
Here's the environment I used:
| Item | Value |
| --- | --- |
| Test date | October 19, 2026 |
| Node.js | v24.22.0 |
| jose | 6.2.12 |
| hono | 4.13.13 |
| Machine | Apple M4 (arm64) |
Implementation: GitHub repository
Let me make the main point clear up front.
Google's DPoP implementation is not fundamentally broken. Its design decisions have reasons behind them, and the performance argument held up in my measurements. The frustrating part for implementers is that some documented behavior differs from reality, while important details aren't documented at all. I reported the "used only once" discrepancy to Google, but the report was closed as Won't Fix. In other words, this was treated as a documentation discrepancy rather than a demonstrated security vulnerability.
> This article expands on my lightning talk, "Google OAuth Apparently Supports DPoP Now, So I Tried It," with measurements and analysis I couldn't fit into the presentation. It is intended for developers who store Google API OAuth refresh tokens on their servers.
---
1. What is DPoP?
DPoP (Demonstrating Proof-of-Possession) is an OAuth 2.0 security mechanism standardized in RFC 9449. It became a Proposed Standard in September 2023, and providers such as Okta and FusionAuth have supported it for some time. What's new is Google's support, not DPoP itself.
A conventional Bearer token follows what Google's blog calls the "finders-keepers model": whoever gets the token can use it. DPoP helps prevent this through sender-constraining:
- The client generates a key pair and keeps the private key to itself.
- For each request to the token endpoint, the client includes a JWT signed with its private key (a DPoP proof).
- The server issues a token bound to the public key.
sequenceDiagram
participant C as Client (BFF)
participant G as Google OAuth
Note over C: Generate an EC P-256 key pair
C->>G: Authorization code exchange + DPoP proof
G-->>C: refresh_token (key-bound)<br/>access_token (Bearer)<br/>DPoP-Nonce
Note over C: Sign each subsequent refresh proof<br/>with the same key
C->>G: refresh_token grant + DPoP proof
G-->>C: New access_token + new DPoP-Nonce
I ran the tests against a Node.js + TypeScript server acting as a Google OAuth confidential client. For JWTs I used jose 6.2.12, which provides ES256 signing and JWK export without additional dependencies. I used a dedicated test client and account; I did not touch any production user's authorization grants.
2. Even the date of support was different from what I expected
Google announced the feature on the Developers Blog on October 15, 2026. But when I checked revisions of the official documentation through the Wayback Machine, the timeline didn't quite line up.
| Date | Event |
| --- | --- |
| April 15, 2026 | No mention of DPoP in Best Practices |
| May 20, 2026 | "Sender-constrain tokens with DPoP" appeared in Best Practices |
| July 31, 2026 | Last update to the DPoP Adoption Guide |
| September 4, 2026 | Google's official DPoP SKILL.md committed to google/skills |
| October 15, 2026 | Announcement on the Developers Blog |
| October 16, 2026 | Web-server documentation updated for DPoP |
Judging from the documentation, support was present by at least May—about five months before the blog announcement. I'm not suggesting anything was concealed; an official blog post's publication date is not necessarily a feature's release date.
And that was just the beginning. The more I verified, the more differences I found, and they fell rather neatly into three categories.
3. Difference #1: Different from the RFC (documented design choices)
RFC 9449 leaves certain optional decisions to the authorization server. Google's choices differ from the general RFC model, but these differences are documented. If you implement solely from the RFC, though, they can still trip you up.
Access tokens remain Bearer tokens
RFC 9449 §5 specifies DPoP as the token_type using MUST language. However, Google's web-server documentation explicitly says that token_type will always be Bearer, even when using DPoP. Only refresh tokens are bound to keys.
This isn't necessarily an RFC violation. The RFC allows an exception in which only the refresh token is sender-constrained when a public client also receives a refresh token.
According to Google's blog, the motivation is performance: avoid the cost of checking asymmetric signatures on every request to a high-QPS API. I measured that cost using a loop of 20,000 operations on Node v24.22.0 / Apple M4.
| Operation | µs/op | ops/s |
| --- | ---: | ---: |
| ES256 sign | 26.2 | 38,227 |
| ES256 verify | 50.5 | 19,787 |
Verification took about twice as long as signing, with throughput around 20,000 verifications per second per core. At my application's scale, that overhead is negligible. At Gmail or Drive scale, it matters. Google's performance argument seems reasonable.
jti isn't random during the authorization-code exchange
RFC 9449 §4.2 requires the jti claim to contain at least 96 bits of pseudorandom data or a UUIDv4. Google, however, uses a deterministic value specifically during the authorization-code exchange:
> the value of jti is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti = BASE64URL(SHA-256(authorization_code))
I checked this using the authorization code and jti from Google's official example:
import hashlib, base64
code = "4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w"
print(base64.urlsafe_b64encode(hashlib.sha256(code.encode()).digest()).rstrip(b"=").decode())
# -> o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (matches the example's jti)
It matched. For refresh requests, Google instead uses an ordinary random value (192 bits). The same jti claim is generated according to different rules in different flows. Implement only one of them, and you'll run into trouble.
Why? The mechanism prescribed by RFC 9449 for binding an authorization code to a key is the dpop_jkt authorization request parameter in §10. Google's authorization request doesn't include dpop_jkt. Instead, it reuses the existing jti claim. Since an authorization code is single-use, the deterministic value is effectively unique for this purpose. My reading is that Google used an existing claim instead of implementing an optional RFC mechanism.
Binding tokens even for confidential clients
RFC 9449 §5 says:
> Refresh tokens issued to confidential clients ... are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism.
Yet Google's Adoption Guide example is a confidential client that sends a client_secret. Meanwhile, Best Practices strongly recommends DPoP for public clients such as SPAs and installed apps, but recommends placing a BFF (a confidential client) in front of an SPA. You want to protect a public client, but the recommended architecture turns it into a confidential-client implementation.
The RFC even notes that client authentication is more flexible than key binding, because it allows a key to be changed without invalidating the refresh token. Google's design knowingly incurs the trade-off that warning describes.
4. Difference #2: The documentation says one thing; the implementation does another
Now for the main event.
An official example contains an incorrectly encoded proof signature
The Adoption Guide contains two sample proofs. I was planning to reuse them, but decided to verify their signatures first. That's where I got stuck.
| Example | Signature length | Result |
| --- | ---: | --- |
| Authorization-code exchange | 64 B | SIGNATURE VALID |
| Token refresh | 70 B | SIGNATURE FAILED |
When I parsed the 70-byte signature as ASN.1, it parsed cleanly:
hex head: 30 44 02 20 ...
ASN.1 DER SEQUENCE? True | declared len: 68
r: tag=0x02 (INTEGER) len=32
s: tag=0x02 (INTEGER) len=32
JWS requires a raw R || S signature (64 bytes for P-256, in IEEE P1363 format), but the example includes an ASN.1 DER-encoded signature. Google's own web-server documentation specifically warns against doing this:
> Note that JWS requires the signature to be in a raw R | S concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format.
You can reproduce the trap within Node.js itself. Even for ES256, different APIs produce different signature formats:
crypto.subtle.sign -> 64 bytes, first byte 0xed (raw P1363)
node:crypto createSign -> 71 bytes, first byte 0x30 (ASN.1 DER)
The node:crypto result was DER-encoded and 71 bytes long, while Google's example was 70 bytes. DER signature lengths vary—roughly 69–72 bytes depending on leading zeros—so don't attempt to identify DER just by checking length === 70.
Another detail: when I decoded the sample proof in the web-server documentation, it turned out to be copied straight from the example in RFC 9449. Its htu is https://server.example.com/token (not Google's endpoint), its iat is 1562262616 (July 4, 2019), and its jti violates Google's own rule stated just three paragraphs earlier. Copy it verbatim, and the htu mismatch gets rejected.
The documentation contradicts itself about carrying a nonce into the next workflow
Can you take the DPoP-Nonce returned by an authorization-code exchange and use it for the first refresh? Two Google documents give opposite answers:
- Adoption Guide (July 31, 2026): Crossing workflows causes an unconditional HTTP 400 rejection.
- Web-server documentation (October 16, 2026): Reusing this nonce avoids an extra round trip.
I tried it. The result was HTTP 400 use_dpop_nonce. The older Adoption Guide matched reality; the newer web-server documentation did not. The first refresh therefore requires a 400 → retry sequence, costing two round trips. A use_dpop_nonce retry loop is required, not an optimization. Without it, the first refresh immediately after authorization fails.
The documented error codes don't match
The web-server documentation says that a mismatched htm/htu, an expired iat, a reused proof, or an invalid signature all yield invalid_dpop_proof. My tests found otherwise:
| Documentation says | Observed | Match? |
| --- | --- | --- |
| htu mismatch → invalid_dpop_proof | invalid_request | ❌ |
| htm mismatch → invalid_dpop_proof | invalid_request | ❌ |
| Expired iat → invalid_dpop_proof | invalid_dpop_proof | ✅ |
| Invalid signature → invalid_dpop_proof | invalid_dpop_proof | ✅ |
The observed categories were more consistent than the documentation suggests:
| Condition | error |
| --- | --- |
| Missing proof / mismatched htm or htu | invalid_request |
| Invalid signature / iat outside accepted range | invalid_dpop_proof |
| Missing, expired, or cross-workflow nonce | use_dpop_nonce |
| Invalid authorization grant | invalid_grant |
extractable: false doesn't translate cleanly to Node.js
Google's official SKILL.md calls this a "CRITICAL SECURITY GUARDRAIL":
> The private key MUST be configured as non-extractable (extractable: false).
But if you follow that instruction literally in Node.js, you can no longer persist the key:
exportKey('jwk') -> InvalidAccessError: key is not extractable
exportKey('pkcs8') -> InvalidAccessError: key is not extractable
A non-extractable key can't be exported from the process. When the server restarts, every user's refresh token bound to that key becomes unusable; the only recovery is to get users to authorize again.
Also, Node.js WebCrypto does not inherently provide a TPM or Secure Enclave. A non-extractable key is just a key held in process memory; Node doesn't provide the "hardware cryptographic boundary" described in SKILL.md. This wording is geared toward browsers or mobile environments, not the Node confidential-client example assumed by the Adoption Guide.
In practice, you need a persistent key strategy: for example, generate with extractable: true, then protect the exported JWK using encryption or KMS. That works, but it means private-key material is now stored outside the process, potentially in your database.
The "a nonce value is used only once" claim didn't hold up
The Adoption Guide says:
> Note that a nonce value is used only once and a missing, invalid, expired, or reused nonce value is rejected with an HTTP 400 response.
First, I tested whether nonces were validated at all. Both a completely bogus nonce and a nonce altered by one character were rejected with 400 use_dpop_nonce. Validation was happening.
Next, I used the same nonce repeatedly:
1st request (same nonce) 200
2nd request (same nonce) 200
3rd request (same nonce) 200
4th request (same nonce) 200
5th request (same nonce) 200
30 seconds later (same nonce) 200
All six succeeded. The nonce was validated, but not consumed.
The same appeared to happen with jti. The web-server documentation says a reused jti causes invalid_dpop_proof, yet two proofs with the same jti both returned 200. I found no evidence that the server was tracking used jti values in these tests.
Together, those behaviors meant I could replay an identical proof byte for byte. Sending a proof with exactly the same jti, nonce, iat, and signature repeatedly returned 200 and issued a new access token each time. I recorded SHA-256 hashes of the submitted proof and the returned tokens. Identical proof hashes resulted in different token hashes, indicating actual reissuance rather than a cached response.
How long would that work? I created one proof and replayed it over time, matching the conditions of a captured proof in which both iat and nonce age together. There was no shortcut for this test; I left my terminal open for an hour.
| Elapsed time since proof creation | HTTP | Result |
| --- | ---: | --- |
| 0–2700 s (0–45 min) | 200 | Replay accepted |
| 3300 s (55 min) | 200 | Replay accepted |
| 3540 s (59 min) | 200 | Replay accepted |
| 3660 s (61 min) | 400 | invalid_dpop_proof |
It succeeded at 59 minutes and failed at 61 minutes with invalid_dpop_proof, not use_dpop_nonce. The nonce was still accepted at 59 minutes, so the iat window appeared to expire first. In this test, a captured proof could be replayed repeatedly for approximately one hour after issuance.
RFC 9449 §11.1 directly addresses this situation:
> Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... As long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay.
The RFC allows nonce reuse provided the server tracks jti and rejects duplicates. In my tests, Google's endpoint accepted reused nonces and didn't appear to reject repeated jti values. That means the condition attached to the RFC's nonce-reuse allowance wasn't met in the observed behavior.
5. Difference #3: Things the documentation doesn't tell you
These are the details I needed for implementation but couldn't find documented. I had to measure them.
The grant is validated before the proof
I supplied a nonexistent refresh token while deliberately corrupting the proof in eight different ways. Even with a DER-encoded signature, a malformed typ, or simply not-a-jwt, every request returned invalid_grant and none returned DPoP-Nonce.
Here's the practical consequence: a grant error can conceal bugs in your proof during debugging. If your refresh token has expired, you may only see invalid_grant, no matter how broken the proof is. This can explain the frustrating "I fixed the proof, but it still doesn't work" experience.
The best debugging sequence is to verify that refresh works without DPoP first, then add DPoP. That way you can isolate the source of failure.
The accepted iat window
Google says expired iat values are rejected but doesn't publish an exact tolerance. RFC 9449 §4.3 leaves that detail to implementers. I narrowed it down to the second using binary search, with matching results from two independent runs.
| Direction | Furthest accepted | Nearest rejected | Boundary |
| --- | ---: | ---: | --- |
| Past | -3599 s | -3600 s | Exactly 3600 s |
| Future | +10 s | +11 s | Exactly 10 s |
3600 seconds into the past, but only 10 seconds into the future: a 360× asymmetry. A generous past window accounts for delays before a proof reaches the server. A tight future window discourages proof pre-generation, which is discussed in RFC 9449 §11.2, "DPoP Proof Pre-generation." The ~one-hour replay window from §4 was governed by the same past-side 3600-second limit. Two independently measured values pointed to the same boundary.
One practical lesson: a server clock just 11 seconds ahead can cause every proof to be rejected. If you're seeing unexplained invalid_dpop_proof errors on a machine without working NTP synchronization, check its clock.
Other undocumented details
- The challenge field in successful responses. It appears in an Adoption Guide example but isn't explained in the prose. Its value matched the DPoP-Nonce header.
- No DPoP advertisement in discovery. RFC 9449 §5.1 defines authorization-server metadata named dpop_signing_alg_values_supported. When I queried accounts.google.com/.well-known/openid-configuration, I found no keys containing dpop. A standards-based client relying on discovery for feature detection cannot discover Google's DPoP support this way.
- The algorithm name for RSA. The Adoption Guide allows RSA but only says it's not recommended because of computational cost. It doesn't specify RS256 versus PS256, and discovery doesn't list either. You have to try it to find out.
6. I reported it, and the result was Won't Fix
I reported the last issue from §4—no apparent jti tracking, nonce reuse, and replayable proofs—to Google Bug Hunters. It conflicted with the documentation and appeared to miss a condition described in the RFC's replay-hardening guidance. I attached reproduction logs with secrets replaced by irreversible hashes.
The result was Won't Fix (Infeasible). Google's reasoning, in summary:
- I hadn't demonstrated account takeover, privilege escalation, or an independent authentication bypass.
- I had acknowledged that the practical impact appeared limited.
- The report didn't meet the Vulnerability Reward Program threshold of a meaningful compromise to another user's data confidentiality or integrity.
I agree with that assessment. An attacker in a position to observe the proof (for example, through an adversary-in-the-middle setup, a TLS-terminating proxy, or logs) could also capture the refresh_token and client_secret from the same request body. And as shown in §5, a proof alone is rejected with invalid_grant; replay requires a valid refresh_token. The replay window did not establish an additional attacker capability in the scenario I tested.
In that sense, the response helped clarify the nature of the finding: an observed discrepancy between implementation and documentation, not a demonstrated exploitable security vulnerability. That is the core message of this article.
It's still worth documenting. Statements like "a nonce value is used only once" and "a reused jti is rejected" lead implementers to expect behavior that I couldn't reproduce. You might implement unnecessary synchronization—or rely on a replay defense that isn't behaving as documented. The report and its outcome are another reminder not to treat official documentation as a substitute for testing.
7. Should you adopt DPoP?
DPoP binding is valuable when you store refresh tokens long-term and want to reduce the risks of that storage being compromised. If a database dump or log leak exposes only a refresh token, an attacker cannot use it without the corresponding key. That's a meaningful benefit.
Its protection is narrower than it might seem, though. Access tokens remain Bearer tokens and can still be used until they expire. Google's blog explicitly says that browser session cookies are outside DPoP's scope, pointing instead to DBSC for that problem. As discussed in §4, on Node.js the private key still needs to be persisted somewhere, weakening the simplistic assumption that "the key never leaves the device."
The biggest adoption cost is migration. To move existing users with unbound refresh tokens to DPoP-bound refresh tokens, you need to revoke the old grant or get a new token using prompt=consent. For a running service, that means asking every existing user to consent again. Key rotation introduces the same issue: obtaining a refresh token bound to a new key requires reauthorization. In other words, your private-key backup strategy effectively becomes your refresh-token backup strategy.
8. Conclusion
Google's DPoP implementation works. Its design choices have rational motivations, and my measurements supported its performance argument for leaving access tokens as Bearer tokens.
The difficulties all came from documentation gaps: differences from the RFC that are documented (§3), claims that don't match observed behavior (§4), and details that aren't documented at all (§5). The most eye-catching finding—the "used only once" claim—was reported and closed as Won't Fix, reinforcing that I had uncovered a documentation discrepancy rather than demonstrated a security vulnerability (§6).
Here are the implementation details I wish the docs had told me:
- The authorization-code jti is BASE64URL(SHA-256(code)), while a refresh uses a random value. Different flows, different rules.
- The first refresh triggers use_dpop_nonce before succeeding. You need a retry loop.
- Mismatched htm/htu returns invalid_request, not invalid_dpop_proof.
- iat acceptance spans 3600 seconds into the past and 10 seconds into the future. Clock skew toward the future can break every proof.
- In a Node.js process, extractable: false isn't compatible with exporting a key for persistent storage. Plan key persistence first.
- Verify refresh works without DPoP before adding DPoP; otherwise, proof errors can be hidden behind invalid_grant.
References
- DPoP Adoption Guide — Google's DPoP implementation guide; proof construction and nonce handling.
- Using OAuth 2.0 for Web Server Applications — Token endpoint parameters, error codes, and notes on prompt=consent.
- OAuth 2.0 Best Practices — Recommendations on sender-constraining.
- Announcing DPoP Support for Google OAuth — Google's announcement, performance trade-offs, and threat model.
- RFC 9449 — DPoP specification; §5 token types, §10 dpop_jkt, §11 security considerations.
- google/skills — dpop-adoption — Google's implementation skill; non-extractable keys and signature formats.
5 reactions · 1 comment
Google OAuthがDPoPに対応したらしいので検証してみたら...なんか違った
> 実装は、ちゃんと動きました。壊れていたのはドキュメントの方です。
この記事の結論
Google OAuth の Refresh Token を DPoP で鍵にバインドする仕組みを、公式ドキュメント・RFC 9449・自分の計測・実際のトークン交換の 4 方向から検証しました。実装はまともに動きます。詰まったところは、1 つ残らず「ドキュメントとの差分」でした。
差分は 3 種類に分かれます。
- RFC と違う — Access Token は Bearer のまま、jti が乱数ではない。設計判断で、ドキュメントには書いてあります
- 書いてあることと、実際が違う — 公式サンプルの署名が壊れている、ドキュメント同士が矛盾している、"a nonce value is used only once" が嘘
- そもそも書いていない — iat の許容範囲、grant と proof の検証順。実測するしかありませんでした
実測した環境です。
| 項目 | 値 |
| --- | --- |
| 検証日 | 2026-10-19 |
| Node.js | v24.22.0 |
| jose | 6.2.12 |
| hono | 4.13.13 |
| マシン | Apple M4 (arm64) |
実装はこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
Google の DPoP 実装そのものは、おかしくありません。 設計判断にはちゃんと理由があり、性能を理由にした主張も測ってみれば妥当でした。それでも実装者が詰まるのは、書いてあることが実際と違ったり、肝心なことが書いていなかったりするからです。最後に見つけた "used only once" の件は Google に報告しましたが、Won't Fix で返ってきました。つまりそれも、仕様の問題ではなく記述の問題だったということです。
> この記事の対象は、Google API 連携で Refresh Token をサーバーに保存している開発者です。
---
1. DPoP とは何か
DPoP(Demonstrating Proof-of-Possession)は RFC 9449 で標準化された OAuth 2.0 のセキュリティプロファイルです。2023 年 9 月に Proposed Standard になっていて、Okta や FusionAuth はずっと前から対応しています。新しいのは Google が対応したことだけで、DPoP 自体は新登場ではありません。
通常の Bearer Token は、Google のブログの表現を借りると "finders-keepers model" です。拾った人が使えてしまう。DPoP はこれを送信者拘束(sender-constraining)で防ぎます。
- クライアントが鍵ペアを生成し、秘密鍵を自分だけが持つ
- トークンエンドポイントへのリクエストごとに、秘密鍵で署名した JWT(DPoP proof)を添える
- サーバーはトークンを公開鍵に紐づけて発行する
sequenceDiagram
participant C as クライアント (BFF)
participant G as Google OAuth
Note over C: EC P-256 の鍵ペアを生成
C->>G: 認可コード交換 + DPoP proof
G-->>C: refresh_token(鍵にバインド)<br/>access_token(Bearer)<br/>DPoP-Nonce
Note over C: 以降 Refresh のたびに<br/>同じ鍵で proof を署名
C->>G: refresh_token grant + DPoP proof
G-->>C: 新しい access_token + 新しい DPoP-Nonce
検証は Node.js + TypeScript のサーバー(Google OAuth の Confidential Client)で行いました。JWT ライブラリは jose 6.2.12 で、依存ゼロで ES256 署名と JWK エクスポートが揃います。専用のテストクライアントとテストアカウントを使い、本番ユーザーの grant には触れていません。
2. まず「いつ対応したのか」が違った
発表は 2026-10-15 の Google Developers Blog です。ところが Wayback Machine で公式ドキュメントの改版を追うと、話が合いません。
| 日付 | 出来事 |
| --- | --- |
| 2026-04-15 | Best Practices に DPoP の記述はゼロ |
| 2026-05-20 | Best Practices に "Sender-constrain tokens with DPoP" が登場 |
| 2026-07-31 | DPoP Adoption Guide の最終更新 |
| 2026-09-04 | google/skills に公式の DPoP SKILL.md が commit |
| 2026-10-15 | Developers Blog で発表 |
| 2026-10-16 | web-server doc が DPoP 対応で更新 |
ドキュメント上は、少なくとも 5 月には対応が入っていたことになります。発表はその約 5 ヶ月後です。隠していたという話ではなく、公式ブログの日付は機能のリリース日とは限らない、というだけの話です。
そしてこれは序の口でした。確かめるほど差分が出てきて、しかもきれいに 3 種類に分かれます。
3. 差分①:RFC と違う(仕様。ちゃんと書いてある)
RFC 9449 はオプションの判断を各認可サーバーに委ねています。Google の選択は RFC の一般仕様とズレますが、これは「書いてある」類の差分です。RFC だけ読んで実装すると、ここで詰まります。
Access Token は Bearer のまま
RFC 9449 §5 は token_type に DPoP を MUST としていますが、web-server doc は「token_type は DPoP を使っても常に Bearer」と明言しています。バインドされるのは Refresh Token だけです。
ただしこれは RFC 違反ではありません。RFC 側に逃げ道があって、public client が Refresh Token も受け取る場合に「Refresh Token だけをバインドする」ことを明示的に許容しています。
理由はブログによると性能で、高 QPS の API で毎回非対称署名を検証するコストを避けたい、とのことです。これは測ってみました。Node v24.22.0 / Apple M4 で 20,000 回のループです。
| 操作 | µs/op | ops/s |
| --- | ---: | ---: |
| ES256 sign | 26.2 | 38,227 |
| ES256 verify | 50.5 | 19,787 |
検証は署名の約 2 倍、1 コアあたり毎秒 2 万回程度でした。自分のアプリの規模なら誤差ですが、Gmail や Drive の規模なら効きます。 Google の言い分は妥当だと思います。
認可コード交換の jti が乱数ではない
RFC 9449 §4.2 は jti に「96 bit 以上の擬似乱数か UUIDv4」を要求します。ところが Google は、認可コード交換のときだけ決定的な値を使います。
> the value of jti is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti = BASE64URL(SHA-256(authorization_code))
公式サンプルに載っている認可コードと jti で、実際に計算して確かめました。
import hashlib, base64
code = "4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w"
print(base64.urlsafe_b64encode(hashlib.sha256(code.encode()).digest()).rstrip(b"=").decode())
# -> o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (サンプルの jti と一致)
一致しました。Refresh のときは逆に普通のランダム値(192 bit)を使います。同じ jti クレームが、フローによって別のルールで決まるので、片方だけ実装すると必ず詰まります。
なぜこうなっているのか。RFC が定める「認可コードを鍵に縛る」方法は §10 の dpop_jkt という認可リクエストパラメータですが、Google の認可リクエストに dpop_jkt はありません。代わりに既存の jti クレームを流用しています。認可コードは一度しか使えないので、決定的な値でも実質的に一意になり、目的は達成できます。RFC の OPTIONAL な機能を実装する代わりに、手持ちのクレームで代替した、と読めます。
Confidential Client なのにバインドする
RFC 9449 §5 はこう書いています。
> Refresh tokens issued to confidential clients ... are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism.
ところが Google の Adoption Guide のサンプルは client_secret を送る Confidential Client です。一方で Best Practices が DPoP を強く勧めるのは SPA やインストール型アプリといった public client で、その SPA には「BFF(= Confidential Client)を立てろ」と言います。public client を守りたいのに、実装は Confidential Client になるというねじれです。
RFC は「鍵に縛るより client 認証の方が柔軟(鍵を変えても Refresh Token を失効させずに済む)」とまで書いていて、Google の構成はその注意点をあえて踏みにいっています。
4. 差分②:書いてあることと、実際が違う
ここからが本題です。
公式サンプルの proof は、署名が壊れている
Adoption Guide には proof のサンプルが 2 つ載っています。そのまま拝借するつもりでしたが、念のため動かす前に署名だけ検証しました。そこで止まりました。
| サンプル | 署名長 | 検証結果 |
| --- | ---: | --- |
| 認可コード交換 | 64 B | SIGNATURE VALID |
| Token Refresh | 70 B | SIGNATURE FAILED |
70 バイトの中身を ASN.1 としてパースすると、きれいに通りました。
hex head: 30 44 02 20 ...
ASN.1 DER SEQUENCE? True | declared len: 68
r: tag=0x02 (INTEGER) len=32
s: tag=0x02 (INTEGER) len=32
JWS が要求するのは raw な R || S(P-256 なら 64 バイト、IEEE P1363 形式)ですが、サンプルは ASN.1 DER のまま貼られています。これは Google 自身が web-server doc で「やるな」と書いていることそのものです。
> Note that JWS requires the signature to be in a raw R | S concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format.
この罠は Node の中でも再現できます。同じ ES256 署名でも、API によって出力形式が違います。
crypto.subtle.sign -> 64 bytes, first byte 0xed (raw P1363)
node:crypto createSign -> 71 bytes, first byte 0x30 (ASN.1 DER)
node:crypto 側は DER で、しかも 71 バイトです。サンプルは 70 バイトでした。DER の長さは leading zero 次第で 69〜72 バイトに揺れるので、length === 70 のような長さチェックで DER を判定してはいけません。
もう 1 つ。web-server doc のサンプル proof をデコードすると、RFC 9449 の例文の丸写しでした。htu が https://server.example.com/token(Google のエンドポイントではない)、iat は 1562262616(2019-07-04)で、jti も同じページの 3 段落上に書いてある自分のルールを破っています。そのままコピーすると htu 不一致で弾かれます。
Nonce の持ち越し可否で、ドキュメント同士が矛盾している
認可コード交換で受け取った DPoP-Nonce を、初回の Refresh にそのまま使えるか。ここで新旧 2 つのドキュメントが逆のことを書いています。
- Adoption Guide(2026-07-31):workflow をまたぐと「無条件に 400 で拒否する」
- web-server doc(2026-10-16):この nonce を使えば「余分な往復を避けられる」
実際に投げたら HTTP 400 use_dpop_nonce でした。古い Adoption Guide の方が正しく、新しい web-server doc が間違っています。 つまり初回 Refresh は必ず 400 → リトライで 2 往復になります。use_dpop_nonce のリトライループは最適化ではなく必須で、省くと認可直後の 1 回目が必ず失敗します。
エラーコードの説明が違う
web-server doc は「htm/htu 不一致、iat 期限切れ、再利用、署名不正はすべて invalid_dpop_proof」と書いていますが、実測は違いました。
| doc の主張 | 実測 | |
| --- | --- | --- |
| htu 不一致 → invalid_dpop_proof | invalid_request | ❌ |
| htm 不一致 → invalid_dpop_proof | invalid_request | ❌ |
| iat 期限切れ → invalid_dpop_proof | invalid_dpop_proof | ✅ |
| 署名不正 → invalid_dpop_proof | invalid_dpop_proof | ✅ |
実際の切り分けはこうなっていて、こちらの方がドキュメントの記述より一貫しています。
| 状況 | error |
| --- | --- |
| proof が無い / htm・htu が噛み合わない | invalid_request |
| 署名が不正 / iat が範囲外 | invalid_dpop_proof |
| nonce が無い・期限切れ・workflow 跨ぎ | use_dpop_nonce |
| grant 自体が不正 | invalid_grant |
extractable: false は、Node には当てはまらない
Google 公式の SKILL.md は、秘密鍵の扱いを「CRITICAL SECURITY GUARDRAIL」としてこう要求します。
> The private key MUST be configured as non-extractable (extractable: false).
ところが Node.js でこれを字義通り守ると、鍵を永続化できなくなります。
exportKey('jwk') -> InvalidAccessError: key is not extractable
exportKey('pkcs8') -> InvalidAccessError: key is not extractable
非 extractable な鍵はプロセスの外に出せません。サーバーが再起動した瞬間、その鍵にバインドされた全ユーザーの Refresh Token が使用不能になり、復旧手段は再同意しかありません。
さらに、Node.js の WebCrypto には TPM も Secure Enclave もありません。非 extractable 鍵はただのプロセスメモリ上の鍵で、SKILL.md が言う「hardware cryptographic boundary」は Node には存在しません。この文言はブラウザやモバイル向けで、Adoption Guide 自身が前提にしている Node の Confidential Client には当てはまらないのです。
現実的には extractable: true で生成し、書き出した JWK を暗号化や KMS で保護して保存することになります。動きますが、その瞬間に秘密鍵成分が DB に乗ります。
"a nonce value is used only once" が、嘘だった
Adoption Guide はこう書いています。
> Note that a nonce value is used only once and a missing, invalid, expired, or reused nonce value is rejected with an HTTP 400 response.
まず「そもそも nonce は検証されているのか」の対照を取りました。でたらめな nonce と 1 文字変えた nonce は、どちらも 400 use_dpop_nonce で弾かれます。検証はされています。
そのうえで、同じ nonce を続けて使いました。
1 回目(同じ nonce) 200
2 回目(同じ nonce) 200
3 回目(同じ nonce) 200
4 回目(同じ nonce) 200
5 回目(同じ nonce) 200
30 秒後(同じ nonce) 200
6 回とも通りました。 検証はされるが、消費はされません。
同じことが jti でも起きます。web-server doc は「再利用された jti は invalid_dpop_proof」と書いていますが、同じ jti を持つ proof を 2 回投げても両方 200 でした。jti は記録されていません。
この 2 つが重なると、proof をバイト単位でそのまま再利用できます。 jti・nonce・iat・署名まで完全に同一の proof を投げ直すと、繰り返し 200 が返り、そのたびに新しい Access Token が発行されました(送った proof の SHA-256 と、返ってきたトークンのハッシュを記録しています。同じ proof から毎回違うトークンが出る=キャッシュではなく本当に再発行されている、ということです)。
いつまで再利用できるのか。1 本の proof を作って、現実に盗まれた proof と同じ条件(iat も nonce も一緒に古くなる)で、時間をおいて投げ直しました。ここだけは早送りができないので、ターミナルを開いたまま 1 時間待ちました。
| 経過(proof 作成から) | HTTP | 結果 |
| --- | ---: | --- |
| 0 〜 2700 秒(0〜45 分) | 200 | 再利用成立 |
| 3300 秒(55 分) | 200 | 再利用成立 |
| 3540 秒(59 分) | 200 | 再利用成立 |
| 3660 秒(61 分) | 400 | invalid_dpop_proof |
59 分では通り、61 分で invalid_dpop_proof(use_dpop_nonce ではない)で落ちました。nonce は 59 分時点でもまだ有効だったので、先に来たのは iat の窓です。捕捉した proof は、発行からおよそ 1 時間、何度でも再利用できます。
ここで RFC 9449 §11.1 を読み直すと、まさにこの条件が書いてあります。
> Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... As long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay.
nonce の使い回しを許していいのは、jti を追跡している場合に限る。Google は jti を追跡しないまま、nonce 再利用だけを許しています。RFC が条件付きで認めた許可の、条件の方を満たしていない状態です。
5. 差分③:そもそも書いていない
実装していて必要になったのに、どこにも書かれていなかったものです。実測するしかありませんでした。
grant は proof より先に検証される
存在しない Refresh Token を送りつつ、proof の方だけを 8 通りに壊してみました。DER 署名の proof でも、typ を壊しても、not-a-jwt を送っても、全部 invalid_grant で、DPoP-Nonce は一度も返りませんでした。
ここから実装上の教訓が出ます。デバッグ中に proof のバグが invalid_grant に隠れるのです。Refresh Token が期限切れだと、proof がどれだけ壊れていても invalid_grant しか返りません。「proof を直したのに直らない」の原因がここにあることがあります。
実装の順序としては「まず DPoP なしで Refresh が通ることを確認してから DPoP を足す」が正解になります。どちらが壊れているのか切り分けられるからです。
iat の許容範囲
Google は「期限切れの iat は弾く」としか書いておらず、秒数を公開していません。RFC 9449 §4.3 も実装依存としています。二分探索で 1 秒まで詰めました(独立した 2 回の実行で一致)。
| 方向 | 通る最遠 | 弾かれる最近 | 値 |
| --- | ---: | ---: | --- |
| 過去 | -3599 s | -3600 s | ちょうど 3600 秒 |
| 未来 | +10 s | +11 s | ちょうど 10 秒 |
過去は 3600 秒、未来は 10 秒。360 倍の非対称です。 過去側が緩いのは proof が届くまでの遅延を見込むため、未来側が厳しいのは未来の iat が「proof の作り置き」を意味するからで、RFC 9449 §11.2 "DPoP Proof Pre-generation" が警戒しているのがこれです。なお §4 で見た「proof を約 1 時間再利用できる」の窓を決めていたのは、この過去側の 3600 秒でした。別々に測った 2 つの数字が同じ値を指したことになります。
実装側の教訓としては、サーバーの時計が未来に 11 秒ずれているだけで全 proof が弾かれます。NTP が効いていない環境で「なぜか invalid_dpop_proof」が出るときは、ここを疑う価値があります。
その他、書かれていなかったもの
- 成功レスポンスの challenge フィールド。 Adoption Guide のサンプルには出てきますが、本文に説明がありません。値は DPoP-Nonce ヘッダーと同一でした
- discovery に DPoP の宣言がない。 RFC 9449 §5.1 は dpop_signing_alg_values_supported という authorization server metadata を定義していますが、accounts.google.com/.well-known/openid-configuration を引いても dpop を含むキーは 1 つもありません。discovery ベースで feature detection する標準準拠のクライアントは、Google の DPoP 対応を検出できません
- RSA を使う場合の alg 名。 Adoption Guide は RSA も可としますが「計算コストが高いので非推奨」とだけ書かれていて、RS256 なのか PS256 なのかは書かれていません。discovery にも無いので、投げて試すしかありません
6. 報告したら Won't Fix だった
§4 の最後の件(jti 未追跡 + nonce 再利用 + proof の再利用)は、ドキュメントとの食い違いであると同時に RFC の hardening 条件も満たしていないので、Google の Bug Hunters に報告しました。再現用の生ログ(秘密は不可逆なハッシュに置き換えたもの)を添えています。
結果は Won't Fix(Infeasible) でした。要旨はこうです。
- アカウント乗っ取り・権限昇格・認証バイパスが示されていない
- 報告者自身が「実用上の影響は限定的」と認めている
- 「他ユーザーの機微データの機密性・完全性を実質的に損なう」という VRP の基準を越えていない
この判断には納得しています。proof を観測できる立場(AiTM、TLS 終端プロキシ、ログ)なら、同じリクエストボディに入っている refresh_token と client_secret も一緒に取れます。しかも §5 のとおり proof 単体では invalid_grant で弾かれるので、再利用には有効な refresh_token が必須です。再利用できる窓があっても、攻撃者の能力を増やしていません。
逆に言うと、この回答で finding の性質が確定しました。セキュリティバグではなく、ドキュメントと実装の食い違いである、と Google 自身が位置づけたということです。この記事の主張そのものです。
それでも書く価値はあると思っています。"a nonce value is used only once" や「再利用された jti は弾く」は、読んで実装すると現実と違うので、その通りに作れば要らない排他制御を書いたり、効いていない前提に寄りかかったりします。報告して「追跡しない」と返ってきた結末まで含めて、公式の記述を鵜呑みにしない材料になるはずです。
7. で、入れるべきなのか
DPoP バインドが効くのは、Refresh Token を長期保存していて、その保存先が漏れるリスクを下げたいケースです。DB ダンプやログ流出で Refresh Token だけが漏れても、鍵がなければ再利用されません。これは確かな価値です。
ただし守備範囲は見かけより狭いです。Access Token は Bearer のままなので有効期限内は使われますし、ブラウザのセッション Cookie は対象外だとブログが明言しています(その領域は DBSC が担当する、という整理です)。そして §4 のとおり Node では秘密鍵を結局どこかに保存することになるので、「鍵は外に出ない」という前提も一段弱くなります。
導入コストで一番重いのは移行です。すでに Refresh Token を持っているユーザーを DPoP バインドに切り替えるには、既存の grant を revoke するか prompt=consent で再発行させるしかありません。稼働中のサービスでは全ユーザーに再同意を踏ませることになります。鍵をローテーションしたい場合も同じで、新しい鍵にバインドされた Refresh Token を得るには再認可が必要です。つまり鍵のバックアップ戦略が、そのまま Refresh Token のバックアップ戦略になります。
8. まとめ
Google の DPoP 実装は、動きます。仕様の選択にも理由があり、Bearer のままにした性能上の判断は測ってみれば妥当でした。
詰まったのは全部、ドキュメントとの差分でした。RFC と違うところ(§3)、書いてあることと実際が違うところ(§4)、そもそも書いていないところ(§5)。一番派手だった "used only once" は報告しましたが、Won't Fix で返ってきて、やはり記述の問題だったことがはっきりしました(§6)。
ドキュメントを読むだけでは出てこない点を置いておきます。
- 認可コード交換の jti は BASE64URL(SHA-256(code))、Refresh は乱数。フローで別ルール
- 初回 Refresh は必ず use_dpop_nonce で 1 回落ちる。リトライループは必須
- htm/htu の不一致は invalid_dpop_proof ではなく invalid_request
- iat は過去 3600 秒・未来 10 秒。時計が未来にずれると全滅する
- Node では extractable: false にできない。鍵の保存方法を先に決める
- まず DPoP なしで Refresh を通してから DPoP を足す。でないと invalid_grant に隠れる
参考
- DPoP Adoption Guide — Google の DPoP 実装ガイド。proof の構成と Nonce の扱い
- Using OAuth 2.0 for Web Server Applications — トークンエンドポイントのパラメータとエラー、prompt=consent の注記
- OAuth 2.0 Best Practices — sender-constraining の推奨対象
- Announcing DPoP Support for Google OAuth — 発表ブログ。性能トレードオフと脅威モデル
- RFC 9449 — DPoP の仕様。§5 token_type、§10 dpop_jkt、§11 セキュリティ考慮
- google/skills — dpop-adoption — 公式の実装スキル。非 extractable 鍵と署名形式
Mentions
google/skills is packaging work. It turns Google product guidance into installable skills, mostly basics, recipes, and Well-Architected topics. My take: spec first automation starts by making documented product knowledge portable. #SystemsLiteracy
Your Agent Changed Last Night and Nobody Deployed Anything: Skill Drift on Google Cloud Skill Registry The incident that never shows up in your deploy log Here is a scenario that's easy to create on Gemini Enterprise Agent Platform today. You build a Managed Agent. You attach a skill from Skill Registry called invoice-triage. It works great. You ship it. Two weeks later, someone on another team updates invoice-triage to handle a new vendor format. They test it with their own agent. It works for them. That same afternoon, your production agent starts misrouting invoices. Nobody touched your agent. No pull request, no deploy, no change ticket. Your CI pipeline is green. Your agent config in Git is identical to last week. What changed is the skill underneath it. I call this skill drift, and in this post I will show you exactly why it happens, how to measure a second hidden problem that comes with it (the context tax of mounting many skills), and a small lockfile pattern that fixes both. Everything here is based on the public Managed Agents API and Skill Registry docs. Both are Preview, so treat the code as a starting point and test it in a sandbox project first. Quick refresher: how skills reach your agent A skill is a folder with a SKILL.md file plus optional scripts/, references/, and assets/. You upload it to Skill Registry as a zipped payload, and the registry keeps a revision history for every update. When you create or update a Managed Agent, you mount skills into the agent's sandbox through base_environment.sources. The docs give you three ways to point at the registry: | Source path | What the agent gets | |---|---| | projects/P/locations/L/skills/SKILL_ID | The default version, whatever it is right now | | projects/P/locations/L/skills/SKILL_ID/skill_versions/VERSION | One exact version, frozen | | projects/P/locations/L/skills | Up to 100 skills from that project and location | The first and third options are the ones people copy from quickstarts. Both are floating references. That is the root of the problem. Problem 1: floating references are :latest tags for agent behavior We learned this lesson years ago with container images. Deploying myapp:latest to production means your production state is defined by whoever pushed last, not by what you reviewed. A skill path without a version is the same thing, except worse in one way: a skill is mostly natural language instructions. A tiny wording change in SKILL.md can change which tool the model calls, what it refuses, or how it formats output. There is no compiler error. There is no failing unit test unless you wrote evals for it. A few details from the docs make this sharper: - UpdateSkill can replace the display name, description, and the whole zipped filesystem in one call. - The skill ID becomes the folder name inside the agent sandbox, so the agent keeps "seeing" the same skill name even after the content changed. - Skill Registry inherits project level IAM. Anyone with write access in the project can update any skill in it. So the blast radius of one skill update is every agent that mounts that skill without a version. Problem 2: the context tax of "mount everything" The third path above is tempting. One line, and your agent has every skill in the project. But think about how skills work. In the Agent Skills pattern, the agent sees each skill's name and description up front so it can decide which one to open. The full SKILL.md body only loads when the skill is picked. That is called progressive disclosure, and it keeps things cheap when you have a handful of skills. With up to 100 skills mounted, those names and descriptions add up. That text rides along on every turn, whether or not any of those skills are relevant. There is also a quality cost. More skills with overlapping descriptions means more chances for the model to pick the wrong one. Two skills named invoice-triage and invoice-review with similar descriptions is a routing bug waiting to happen. You cannot manage what you do not measure, so let's measure it. Measuring the skill metadata budget This script lists every skill in a project and location, joins the names and descriptions the way an agent would roughly see them, and counts tokens with the Count Tokens API. # skill_context_tax.py import os import requests import google.auth from google.auth.transport.requests import Request from google import genai PROJECT = os.environ["GCP_PROJECT_ID"] LOCATION = os.environ.get("SKILL_LOCATION", "us-central1") MODEL = os.environ["MODEL_ID"] # the Gemini model your agent uses BASE = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT}/locations/{LOCATION}" def authed_session(): creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) creds.refresh(Request()) s = requests.Session() s.headers["Authorization"] = f"Bearer {creds.token}" return s def list_skills(s): skills, token = [], None while True: params = {"pageToken": token} if token else {} r = s.get(f"{BASE}/skills", params=params) r.raise_for_status() body = r.json() skills.extend(body.get("skills", [])) token = body.get("nextPageToken") if not token: return skills def main(): s = authed_session() skills = list_skills(s) catalog = "\n".join( f"- {sk.get('displayName', '')}: {sk.get('description', '')}" for sk in skills ) client = genai.Client(vertexai=True, project=PROJECT, location="global") total = client.models.count_tokens(model=MODEL, contents=catalog).total_tokens print(f"Skills in {LOCATION}: {len(skills)}") print(f"Metadata tokens per turn (floor estimate): {total}") if skills: print(f"Average per skill: {total / len(skills):.1f}") if __name__ == "__main__": main() Two honest caveats about this number: 1. It is a floor. The base agent may wrap each skill in extra formatting, file paths, or instructions that you cannot see. 2. It uses the registry description. If the description in your SKILL.md frontmatter is longer, the real cost is higher. Keep the two in sync. To turn it into a monthly number, multiply by your turns per month and your model's input price. Here is the math with made up example numbers, just to show the shape: 80 skills x 70 tokens = 5,600 tokens of skill metadata per turn 5,600 x 2,000,000 turns/month = 11.2 billion input tokens per month Plug in your own skill count, turn volume, and the current price from the pricing page. If the result surprises you, that is the point of measuring. The fix: a skill lockfile The pattern is the same one we use for package managers. Floating references are fine for development. Production gets exact, reviewed versions recorded in a file that lives in Git. The flow looks like this: author updates skill -> registry creates new revision -> staging agent (floating) picks it up -> evals run against staging -> pass? update skills.lock.json in a PR -> merge -> CI pins prod agent to locked versions Step 1: generate and check the lockfile # skill_lock.py import json import os import sys import requests import google.auth from google.auth.transport.requests import Request PROJECT = os.environ["GCP_PROJECT_ID"] LOCATION = os.environ.get("SKILL_LOCATION", "us-central1") BASE = f"https://{LOCATION}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT}/locations/{LOCATION}" LOCKFILE = "skills.lock.json" def authed_session(): creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) creds.refresh(Request()) s = requests.Session() s.headers["Authorization"] = f"Bearer {creds.token}" return s def latest_revision(s, skill_id): revs, token = [], None while True: params = {"pageToken": token} if token else {} r = s.get(f"{BASE}/skills/{skill_id}/revisions", params=params) r.raise_for_status() body = r.json() revs.extend(body.get("skillRevisions", [])) token = body.get("nextPageToken") if not token: break if not revs: return None revs.sort(key=lambda rev: rev["createTime"]) newest = revs[-1] return {"revision": newest["name"].split("/")[-1], "created": newest["createTime"]} def lock(skill_ids): s = authed_session() data = {"project": PROJECT, "location": LOCATION, "skills": {}} for sid in skill_ids: data["skills"][sid] = latest_revision(s, sid) with open(LOCKFILE, "w") as f: json.dump(data, f, indent=2) print(f"Wrote {LOCKFILE} with {len(skill_ids)} skills") def check(): s = authed_session() with open(LOCKFILE) as f: data = json.load(f) drift = [] for sid, locked in data["skills"].items(): current = latest_revision(s, sid) if current is None or current["revision"] != locked["revision"]: drift.append((sid, locked["revision"], current and current["revision"])) for sid, old, new in drift: print(f"DRIFT {sid}: locked {old}, registry now has {new}") sys.exit(1 if drift else 0) if __name__ == "__main__": if sys.argv[1] == "lock": lock(sys.argv[2:]) elif sys.argv[1] == "check": check() Usage: python skill_lock.py lock invoice-triage vendor-lookup refund-policy python skill_lock.py check # exits 1 if any skill moved The check command is your drift alarm. It does not block anyone from updating a skill. It just tells you, loudly, that the registry no longer matches what production was approved with. Step 2: pin the production agent to the lockfile The agent update uses PATCH with update_mask=base_environment. There is no update method in the Python SDK for agents yet, so this goes through REST. One gotcha worth calling out: the docs examples resend the whole base_environment object, including the network allowlist. Treat this update as a full replace, not a merge. If you send only sources, plan for the possibility that other environment settings go back to defaults. Network access is off by default, so a lost allowlist can break an agent that calls external APIs. # pin_agent.py import json import os import requests import google.auth from google.auth.transport.requests import Request PROJECT = os.environ["GCP_PROJECT_ID"] AGENT_ID = os.environ["AGENT_ID"] # The agent docs call this segment "skill_versions", while the registry # returns revision names under "revisions". Confirm which one your project # accepts, then set it here. PIN_SEGMENT = os.environ.get("PIN_SEGMENT", "skill_versions") ALLOWLIST = json.loads(os.environ.get("NETWORK_ALLOWLIST", '[{"domain": "api.example.com"}]')) AGENT_URL = ( f"https://aiplatform.googleapis.com/v1beta1/projects/{PROJECT}" f"/locations/global/agents/{AGENT_ID}?update_mask=base_environment" ) def main(): with open("skills.lock.json") as f: lock = json.load(f) sources = [] for sid, info in lock["skills"].items(): path = ( f"projects/{lock['project']}/locations/{lock['location']}" f"/skills/{sid}/{PIN_SEGMENT}/{info['revision']}" ) sources.append({"type": "skill_registry", "source": path, "target": "/.agent/skills"}) body = { "name": AGENT_ID, "base_environment": { "type": "remote", "sources": sources, "network": {"allowlist": ALLOWLIST}, }, } creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) creds.refresh(Request()) r = requests.patch( AGENT_URL, headers={"Authorization": f"Bearer {creds.token}", "Content-Type": "application/json"}, json=body, ) r.raise_for_status() print("Update started:", r.json().get("name")) if __name__ == "__main__": main() The update is a long running operation, so poll the returned operation name before you call the rollout done. Step 3: run the drift check on a schedule Any scheduler works. Here is a minimal GitHub Actions version that runs every morning and fails loudly on drift: name: skill-drift-check on: schedule: - cron: "0 12 * * *" workflow_dispatch: jobs: check: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} service_account: ${{ secrets.DRIFT_SA }} - run: pip install requests google-auth - run: python skill_lock.py check env: GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} SKILL_LOCATION: us-central1 The service account only needs read access, so roles/aiplatform.viewer is enough for this job. A few more things I noticed while building this Skill IDs are a namespace you cannot get back quickly. Skill IDs are immutable, and after a delete the ID stays reserved for a while (the docs mention 24 hours in one place and permanent reservation in another, so plan as if it is long). Pick names carefully, and do not use delete plus recreate as a "rollback" strategy. Pinning a previous version is the rollback. Regions do not line up one to one. Managed Agents only support the global location, while Skill Registry lives in specific regions (us-central1, europe-west4, us-east5 at the time of writing). Your mount path includes the skill's region, so write that region into your lockfile instead of assuming it. Cloud Storage mounts skip validation. You can also mount skills straight from a bucket. The docs say the backend does not validate folder content in that case. That makes the registry path the safer choice for production, because uploads go through payload checks and you get revision history. Write descriptions for routing, not marketing. Since descriptions are both your token cost and the model's routing signal, short and specific beats long and friendly. "Classifies AP invoices by vendor and urgency. Not for refunds." does more work than a paragraph. Limitations and caveats - Skill Registry and Managed Agents are Pre-GA. APIs, paths, and field names can change, and the docs say not to use confidential data with the Managed Agents preview. - At the time of writing, Skill Registry does not support VPC Service Controls, CMEK, or HIPAA. Check the compliance table before you design around it. - The docs use two different names for a pinned skill path (skill_versions on the agent side, revisions on the registry side). My script makes this configurable because I could not confirm which is final. Verify in your own project. - The token numbers from the context tax script are a lower bound, not an exact bill. The base agent may add text you cannot see. - A lockfile tells you what changed. It does not tell you whether the change is good. Pair it with evals on a staging agent before promoting. Wrapping up Skills make agents easier to build and share across teams. That same sharing means a skill update is a production change for every agent that mounts it without a version. Three habits fix most of it: pin versions in production, measure what "mount everything" costs you, and run a daily drift check against a lockfile in Git. None of this needs new infrastructure. It is the same discipline we already apply to container images and package managers, pointed at a new kind of artifact. If you try this pattern, I would love to hear what your skill metadata token count came out to. References - Create and manage agents (attach skills, pinning, ListSkills mount): docs.cloud.google.com - Create and manage skills in Skill Registry: docs.cloud.google.com - Google Skills repository (SKILL.md examples): google/skills - Agent Skills format conventions: agentskills.io
Using npx skills add google/skills, you can pull Agent Skills to set up Google Cloud environments and AI agents. google/skills
@theGOATmeeh Check out our skill! github.com/google/skills/tree/main/skills/develop…
@tendon0 こちらですかね? google/skills
Tired of writing custom tools for every Google Cloud service? Agent Skills gives your AI agents pre-built GCP workflows. google/skills
Google OAuthがDPoPに対応したらしいので検証してみたら...なんか違った 実装は、ちゃんと動きました。壊れていたのはドキュメントの方です。 この記事の結論 Google OAuth の Refresh Token を DPoP で鍵にバインドする仕組みを、公式ドキュメント・RFC 9449・自分の計測・実際のトークン交換の 4 方向から検証しました。実装はまともに動きます。詰まったところは、1 つ残らず「ドキュメントとの差分」でした。 差分は 3 種類に分かれます。 RFC と違う — Access Token は Bearer のまま、jti が乱数ではない。設計判断で、ドキュメントには書いてあります 書いてあることと、実際が違う — 公式サンプルの署名が壊れている、ドキュメント同士が矛盾している、"a nonce value is used only once" が嘘 そもそも書いていない — iat の許容範囲、grant と proof の検証順。実測するしかありませんでした 実測した環境です。 項目 値 検証日 2026-10-19 Node.js v24.22.0 jose 6.2.12 hono 4.13.13 マシン Apple M4 (arm64) 実装はこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 Google の DPoP 実装そのものは、おかしくありません。 設計判断にはちゃんと理由があり、性能を理由にした主張も測ってみれば妥当でした。それでも実装者が詰まるのは、書いてあることが実際と違ったり、肝心なことが書いていなかったりするからです。最後に見つけた "used only once" の件は Google に報告しましたが、Won't Fix で返ってきました。つまりそれも、仕様の問題ではなく記述の問題だったということです。 この記事の対象は、Google API 連携で Refresh Token をサーバーに保存している開発者です。 1. DPoP とは何か DPoP(Demonstrating Proof-of-Possession)は RFC 9449 で標準化された OAuth 2.0 のセキュリティプロファイルです。2023 年 9 月に Proposed Standard になっていて、Okta や FusionAuth はずっと前から対応しています。新しいのは Google が対応したことだけで、DPoP 自体は新登場ではありません。 通常の Bearer Token は、Google のブログの表現を借りると "finders-keepers model" です。拾った人が使えてしまう。DPoP はこれを送信者拘束(sender-constraining)で防ぎます。 クライアントが鍵ペアを生成し、秘密鍵を自分だけが持つ トークンエンドポイントへのリクエストごとに、秘密鍵で署名した JWT(DPoP proof)を添える サーバーはトークンを公開鍵に紐づけて発行する 検証は Node.js + TypeScript のサーバー(Google OAuth の Confidential Client)で行いました。JWT ライブラリは jose 6.2.12 で、依存ゼロで ES256 署名と JWK エクスポートが揃います。専用のテストクライアントとテストアカウントを使い、本番ユーザーの grant には触れていません。 2. まず「いつ対応したのか」が違った 発表は 2026-10-15 の Google Developers Blog です。ところが Wayback Machine で公式ドキュメントの改版を追うと、話が合いません。 日付 出来事 2026-04-15 Best Practices に DPoP の記述はゼロ 2026-05-20 Best Practices に "Sender-constrain tokens with DPoP" が登場 2026-07-31 DPoP Adoption Guide の最終更新 2026-09-04 google/skills に公式の DPoP SKILL.md が commit 2026-10-15 Developers Blog で発表 2026-10-16 web-server doc が DPoP 対応で更新 ドキュメント上は、少なくとも 5 月には対応が入っていたことになります。発表はその約 5 ヶ月後です。隠していたという話ではなく、公式ブログの日付は機能のリリース日とは限らない、というだけの話です。 そしてこれは序の口でした。確かめるほど差分が出てきて、しかもきれいに 3 種類に分かれます。 3. 差分①:RFC と違う(仕様。ちゃんと書いてある) RFC 9449 はオプションの判断を各認可サーバーに委ねています。Google の選択は RFC の一般仕様とズレますが、これは「書いてある」類の差分です。RFC だけ読んで実装すると、ここで詰まります。 Access Token は Bearer のまま RFC 9449 §5 は token_type に DPoP を MUST としていますが、web-server doc は「token_type は DPoP を使っても常に Bearer」と明言しています。バインドされるのは Refresh Token だけです。 ただしこれは RFC 違反ではありません。RFC 側に逃げ道があって、public client が Refresh Token も受け取る場合に「Refresh Token だけをバインドする」ことを明示的に許容しています。 理由はブログによると性能で、高 QPS の API で毎回非対称署名を検証するコストを避けたい、とのことです。これは測ってみました。Node v24.22.0 / Apple M4 で 20,000 回のループです。 操作 µs/op ops/s ES256 sign 26.2 38,227 ES256 verify 50.5 19,787 検証は署名の約 2 倍、1 コアあたり毎秒 2 万回程度でした。自分のアプリの規模なら誤差ですが、Gmail や Drive の規模なら効きます。 Google の言い分は妥当だと思います。 認可コード交換の jti が乱数ではない RFC 9449 §4.2 は jti に「96 bit 以上の擬似乱数か UUIDv4」を要求します。ところが Google は、認可コード交換のときだけ決定的な値を使います。 the value of jti is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti = BASE64URL(SHA-256(authorization_code)) 公式サンプルに載っている認可コードと jti で、実際に計算して確かめました。 import hashlib, base64 code = "4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w" print(base64.urlsafe_b64encode(hashlib.sha256(code.encode()).digest()).rstrip(b"=").decode()) -> o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (サンプルの jti と一致) 一致しました。Refresh のときは逆に普通のランダム値(192 bit)を使います。同じ jti クレームが、フローによって別のルールで決まるので、片方だけ実装すると必ず詰まります。 なぜこうなっているのか。RFC が定める「認可コードを鍵に縛る」方法は §10 の dpop_jkt という認可リクエストパラメータですが、Google の認可リクエストに dpop_jkt はありません。代わりに既存の jti クレームを流用しています。認可コードは一度しか使えないので、決定的な値でも実質的に一意になり、目的は達成できます。RFC の OPTIONAL な機能を実装する代わりに、手持ちのクレームで代替した、と読めます。 Confidential Client なのにバインドする RFC 9449 §5 はこう書いています。 Refresh tokens issued to confidential clients ... are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism. ところが Google の Adoption Guide のサンプルは client_secret を送る Confidential Client です。一方で Best Practices が DPoP を強く勧めるのは SPA やインストール型アプリといった public client で、その SPA には「BFF(= Confidential Client)を立てろ」と言います。public client を守りたいのに、実装は Confidential Client になるというねじれです。 RFC は「鍵に縛るより client 認証の方が柔軟(鍵を変えても Refresh Token を失効させずに済む)」とまで書いていて、Google の構成はその注意点をあえて踏みにいっています。 4. 差分②:書いてあることと、実際が違う ここからが本題です。 公式サンプルの proof は、署名が壊れている Adoption Guide には proof のサンプルが 2 つ載っています。そのまま拝借するつもりでしたが、念のため動かす前に署名だけ検証しました。そこで止まりました。 サンプル 署名長 検証結果 認可コード交換 64 B SIGNATURE VALID Token Refresh 70 B SIGNATURE FAILED 70 バイトの中身を ASN.1 としてパースすると、きれいに通りました。 hex head: 30 44 02 20 ... ASN.1 DER SEQUENCE? True | declared len: 68 r: tag=0x02 (INTEGER) len=32 s: tag=0x02 (INTEGER) len=32 JWS が要求するのは raw な R || S(P-256 なら 64 バイト、IEEE P1363 形式)ですが、サンプルは ASN.1 DER のまま貼られています。これは Google 自身が web-server doc で「やるな」と書いていることそのものです。 Note that JWS requires the signature to be in a raw R | S concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format. この罠は Node の中でも再現できます。同じ ES256 署名でも、API によって出力形式が違います。 crypto.subtle.sign -> 64 bytes, first byte 0xed (raw P1363) node:crypto createSign -> 71 bytes, first byte 0x30 (ASN.1 DER) node:crypto 側は DER で、しかも 71 バイトです。サンプルは 70 バイトでした。DER の長さは leading zero 次第で 69〜72 バイトに揺れるので、length === 70 のような長さチェックで DER を判定してはいけません。 もう 1 つ。web-server doc のサンプル proof をデコードすると、RFC 9449 の例文の丸写しでした。htu が server.example.com のエンドポイントではない)、iat は 1562262616(2019-07-04)で、jti も同じページの 3 段落上に書いてある自分のルールを破っています。そのままコピーすると htu 不一致で弾かれます。 Nonce の持ち越し可否で、ドキュメント同士が矛盾している 認可コード交換で受け取った DPoP-Nonce を、初回の Refresh にそのまま使えるか。ここで新旧 2 つのドキュメントが逆のことを書いています。 Adoption Guide(2026-07-31):workflow をまたぐと「無条件に 400 で拒否する」 web-server doc(2026-10-16):この nonce を使えば「余分な往復を避けられる」 実際に投げたら HTTP 400 use_dpop_nonce でした。古い Adoption Guide の方が正しく、新しい web-server doc が間違っています。 つまり初回 Refresh は必ず 400 → リトライで 2 往復になります。use_dpop_nonce のリトライループは最適化ではなく必須で、省くと認可直後の 1 回目が必ず失敗します。 エラーコードの説明が違う web-server doc は「htm/htu 不一致、iat 期限切れ、再利用、署名不正はすべて invalid_dpop_proof」と書いていますが、実測は違いました。 doc の主張 実測 htu 不一致 → invalid_dpop_proof invalid_request ❌ htm 不一致 → invalid_dpop_proof invalid_request ❌ iat 期限切れ → invalid_dpop_proof invalid_dpop_proof ✅ 署名不正 → invalid_dpop_proof invalid_dpop_proof ✅ 実際の切り分けはこうなっていて、こちらの方がドキュメントの記述より一貫しています。 状況 error proof が無い / htm・htu が噛み合わない invalid_request 署名が不正 / iat が範囲外 invalid_dpop_proof nonce が無い・期限切れ・workflow 跨ぎ use_dpop_nonce grant 自体が不正 invalid_grant extractable: false は、Node には当てはまらない Google 公式の SKILL.md は、秘密鍵の扱いを「CRITICAL SECURITY GUARDRAIL」としてこう要求します。 The private key MUST be configured as non-extractable (extractable: false). ところが Node.js でこれを字義通り守ると、鍵を永続化できなくなります。 exportKey('jwk') -> InvalidAccessError: key is not extractable exportKey('pkcs8') -> InvalidAccessError: key is not extractable 非 extractable な鍵はプロセスの外に出せません。サーバーが再起動した瞬間、その鍵にバインドされた全ユーザーの Refresh Token が使用不能になり、復旧手段は再同意しかありません。 さらに、Node.js の WebCrypto には TPM も Secure Enclave もありません。非 extractable 鍵はただのプロセスメモリ上の鍵で、SKILL.md が言う「hardware cryptographic boundary」は Node には存在しません。この文言はブラウザやモバイル向けで、Adoption Guide 自身が前提にしている Node の Confidential Client には当てはまらないのです。 現実的には extractable: true で生成し、書き出した JWK を暗号化や KMS で保護して保存することになります。動きますが、その瞬間に秘密鍵成分が DB に乗ります。 "a nonce value is used only once" が、嘘だった Adoption Guide はこう書いています。 Note that a nonce value is used only once and a missing, invalid, expired, or reused nonce value is rejected with an HTTP 400 response. まず「そもそも nonce は検証されているのか」の対照を取りました。でたらめな nonce と 1 文字変えた nonce は、どちらも 400 use_dpop_nonce で弾かれます。検証はされています。 そのうえで、同じ nonce を続けて使いました。 1 回目(同じ nonce) 200 2 回目(同じ nonce) 200 3 回目(同じ nonce) 200 4 回目(同じ nonce) 200 5 回目(同じ nonce) 200 30 秒後(同じ nonce) 200 6 回とも通りました。 検証はされるが、消費はされません。 同じことが jti でも起きます。web-server doc は「再利用された jti は invalid_dpop_proof」と書いていますが、同じ jti を持つ proof を 2 回投げても両方 200 でした。jti は記録されていません。 この 2 つが重なると、proof をバイト単位でそのまま再利用できます。 jti・nonce・iat・署名まで完全に同一の proof を投げ直すと、繰り返し 200 が返り、そのたびに新しい Access Token が発行されました(送った proof の SHA-256 と、返ってきたトークンのハッシュを記録しています。同じ proof から毎回違うトークンが出る=キャッシュではなく本当に再発行されている、ということです)。 いつまで再利用できるのか。1 本の proof を作って、現実に盗まれた proof と同じ条件(iat も nonce も一緒に古くなる)で、時間をおいて投げ直しました。ここだけは早送りができないので、ターミナルを開いたまま 1 時間待ちました。 経過(proof 作成から) HTTP 結果 0 〜 2700 秒(0〜45 分) 200 再利用成立 3300 秒(55 分) 200 再利用成立 3540 秒(59 分) 200 再利用成立 3660 秒(61 分) 400 invalid_dpop_proof 59 分では通り、61 分で invalid_dpop_proof(use_dpop_nonce ではない)で落ちました。nonce は 59 分時点でもまだ有効だったので、先に来たのは iat の窓です。捕捉した proof は、発行からおよそ 1 時間、何度でも再利用できます。 ここで RFC 9449 §11.1 を読み直すと、まさにこの条件が書いてあります。 Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... As long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay. nonce の使い回しを許していいのは、jti を追跡している場合に限る。Google は jti を追跡しないまま、nonce 再利用だけを許しています。RFC が条件付きで認めた許可の、条件の方を満たしていない状態です。 5. 差分③:そもそも書いていない 実装していて必要になったのに、どこにも書かれていなかったものです。実測するしかありませんでした。 grant は proof より先に検証される 存在しない Refresh Token を送りつつ、proof の方だけを 8 通りに壊してみました。DER 署名の proof でも、typ を壊しても、not-a-jwt を送っても、全部 invalid_grant で、DPoP-Nonce は一度も返りませんでした。 ここから実装上の教訓が出ます。デバッグ中に proof のバグが invalid_grant に隠れるのです。Refresh Token が期限切れだと、proof がどれだけ壊れていても invalid_grant しか返りません。「proof を直したのに直らない」の原因がここにあることがあります。 実装の順序としては「まず DPoP なしで Refresh が通ることを確認してから DPoP を足す」が正解になります。どちらが壊れているのか切り分けられるからです。 iat の許容範囲 Google は「期限切れの iat は弾く」としか書いておらず、秒数を公開していません。RFC 9449 §4.3 も実装依存としています。二分探索で 1 秒まで詰めました(独立した 2 回の実行で一致)。 方向 通る最遠 弾かれる最近 値 過去 -3599 s -3600 s ちょうど 3600 秒 未来 +10 s +11 s ちょうど 10 秒 過去は 3600 秒、未来は 10 秒。360 倍の非対称です。 過去側が緩いのは proof が届くまでの遅延を見込むため、未来側が厳しいのは未来の iat が「proof の作り置き」を意味するからで、RFC 9449 §11.2 "DPoP Proof Pre-generation" が警戒しているのがこれです。なお §4 で見た「proof を約 1 時間再利用できる」の窓を決めていたのは、この過去側の 3600 秒でした。別々に測った 2 つの数字が同じ値を指したことになります。 実装側の教訓としては、サーバーの時計が未来に 11 秒ずれているだけで全 proof が弾かれます。NTP が効いていない環境で「なぜか invalid_dpop_proof」が出るときは、ここを疑う価値があります。 その他、書かれていなかったもの 成功レスポンスの challenge フィールド。 Adoption Guide のサンプルには出てきますが、本文に説明がありません。値は DPoP-Nonce ヘッダーと同一でした discovery に DPoP の宣言がない。 RFC 9449 §5.1 は dpop_signing_alg_values_supported という authorization server metadata を定義していますが、accounts.google.com/.well-known/openid-configuration を引いても dpop を含むキーは 1 つもありません。discovery ベースで feature detection する標準準拠のクライアントは、Google の DPoP 対応を検出できません RSA を使う場合の alg 名。 Adoption Guide は RSA も可としますが「計算コストが高いので非推奨」とだけ書かれていて、RS256 なのか PS256 なのかは書かれていません。discovery にも無いので、投げて試すしかありません 6. 報告したら Won't Fix だった §4 の最後の件(jti 未追跡 + nonce 再利用 + proof の再利用)は、ドキュメントとの食い違いであると同時に RFC の hardening 条件も満たしていないので、Google の Bug Hunters に報告しました。再現用の生ログ(秘密は不可逆なハッシュに置き換えたもの)を添えています。 結果は Won't Fix(Infeasible) でした。要旨はこうです。 アカウント乗っ取り・権限昇格・認証バイパスが示されていない 報告者自身が「実用上の影響は限定的」と認めている 「他ユーザーの機微データの機密性・完全性を実質的に損なう」という VRP の基準を越えていない この判断には納得しています。proof を観測できる立場(AiTM、TLS 終端プロキシ、ログ)なら、同じリクエストボディに入っている refresh_token と client_secret も一緒に取れます。しかも §5 のとおり proof 単体では invalid_grant で弾かれるので、再利用には有効な refresh_token が必須です。再利用できる窓があっても、攻撃者の能力を増やしていません。 逆に言うと、この回答で finding の性質が確定しました。セキュリティバグではなく、ドキュメントと実装の食い違いである、と Google 自身が位置づけたということです。この記事の主張そのものです。 それでも書く価値はあると思っています。"a nonce value is used only once" や「再利用された jti は弾く」は、読んで実装すると現実と違うので、その通りに作れば要らない排他制御を書いたり、効いていない前提に寄りかかったりします。報告して「追跡しない」と返ってきた結末まで含めて、公式の記述を鵜呑みにしない材料になるはずです。 7. で、入れるべきなのか DPoP バインドが効くのは、Refresh Token を長期保存していて、その保存先が漏れるリスクを下げたいケースです。DB ダンプやログ流出で Refresh Token だけが漏れても、鍵がなければ再利用されません。これは確かな価値です。 ただし守備範囲は見かけより狭いです。Access Token は Bearer のままなので有効期限内は使われますし、ブラウザのセッション Cookie は対象外だとブログが明言しています(その領域は DBSC が担当する、という整理です)。そして §4 のとおり Node では秘密鍵を結局どこかに保存することになるので、「鍵は外に出ない」という前提も一段弱くなります。 導入コストで一番重いのは移行です。すでに Refresh Token を持っているユーザーを DPoP バインドに切り替えるには、既存の grant を revoke するか prompt=consent で再発行させるしかありません。稼働中のサービスでは全ユーザーに再同意を踏ませることになります。鍵をローテーションしたい場合も同じで、新しい鍵にバインドされた Refresh Token を得るには再認可が必要です。つまり鍵のバックアップ戦略が、そのまま Refresh Token のバックアップ戦略になります。 8. まとめ Google の DPoP 実装は、動きます。仕様の選択にも理由があり、Bearer のままにした性能上の判断は測ってみれば妥当でした。 詰まったのは全部、ドキュメントとの差分でした。RFC と違うところ(§3)、書いてあることと実際が違うところ(§4)、そもそも書いていないところ(§5)。一番派手だった "used only once" は報告しましたが、Won't Fix で返ってきて、やはり記述の問題だったことがはっきりしました(§6)。 ドキュメントを読むだけでは出てこない点を置いておきます。 認可コード交換の jti は BASE64URL(SHA-256(code))、Refresh は乱数。フローで別ルール 初回 Refresh は必ず use_dpop_nonce で 1 回落ちる。リトライループは必須 htm/htu の不一致は invalid_dpop_proof ではなく invalid_request iat は過去 3600 秒・未来 10 秒。時計が未来にずれると全滅する Node では extractable: false にできない。鍵の保存方法を先に決める まず DPoP なしで Refresh を通してから DPoP を足す。でないと invalid_grant に隠れる 参考 DPoP Adoption Guide — Google の DPoP 実装ガイド。proof の構成と Nonce の扱い Using OAuth 2.0 for Web Server Applications — トークンエンドポイントのパラメータとエラー、prompt=consent の注記 OAuth 2.0 Best Practices — sender-constraining の推奨対象 Announcing DPoP Support for Google OAuth — 発表ブログ。性能トレードオフと脅威モデル RFC 9449 — DPoP の仕様。§5 token_type、§10 dpop_jkt、§11 セキュリティ考慮 google/skills — dpop-adoption — 公式の実装スキル。非 extractable 鍵と署名形式
Google Just Put 166 Claude Skills on GitHub for Free Free 7-day Claude course when you subscribe → dub.sh Google just put 166 of its own Claude skills on GitHub for free. It's called Google Skills: the official agent skills for Google's own products. BigQuery, Firebase, Cloud Run, the Gemini API, Google Ads, Analytics and more. These aren't prompts you paste in and hope. They're skills you drop into Claude, so it already knows how Google's tools work before you ask. The Google Skill Finder loads only the skill a task needs, so it costs almost nothing until a lookup happens. Over 21,000 developers have starred it. One command installs it in Claude Code or Codex: npx skills add google/skills Google Skills: google/skills Comment "GOOGLE" and I'll send you the install command. #claudecode #claude #google #aiagents #shorts
I Tested Google's DPoP Implementation. The Documentation Told a Different Story. > The implementation worked. It was the documentation that let me down. TL;DR I investigated Google's mechanism for binding OAuth refresh tokens to a cryptographic key using DPoP from four angles: the official documentation, RFC 9449, my own measurements, and actual token exchanges. The implementation itself worked as intended. Every obstacle I encountered came down to a difference between the documentation and observed behavior. The differences fell into three categories: - Different from the RFC: Access tokens remain Bearer tokens, and jti isn't random in the authorization-code flow. These are design choices documented by Google. - Different from what the documentation claims: An official example contains an incorrectly encoded signature, different documentation pages contradict one another, and the claim that "a nonce value is used only once" doesn't match observed behavior. - Not documented at all: The accepted iat window and the order in which the grant and DPoP proof are validated. I had to measure these myself. Here's the environment I used: | Item | Value | | --- | --- | | Test date | October 19, 2026 | | Node.js | v24.22.0 | | jose | 6.2.12 | | hono | 4.13.13 | | Machine | Apple M4 (arm64) | Implementation: GitHub repository Let me make the main point clear up front. Google's DPoP implementation is not fundamentally broken. Its design decisions have reasons behind them, and the performance argument held up in my measurements. The frustrating part for implementers is that some documented behavior differs from reality, while important details aren't documented at all. I reported the "used only once" discrepancy to Google, but the report was closed as Won't Fix. In other words, this was treated as a documentation discrepancy rather than a demonstrated security vulnerability. > This article expands on my lightning talk, "Google OAuth Apparently Supports DPoP Now, So I Tried It," with measurements and analysis I couldn't fit into the presentation. It is intended for developers who store Google API OAuth refresh tokens on their servers. --- 1. What is DPoP? DPoP (Demonstrating Proof-of-Possession) is an OAuth 2.0 security mechanism standardized in RFC 9449. It became a Proposed Standard in September 2023, and providers such as Okta and FusionAuth have supported it for some time. What's new is Google's support, not DPoP itself. A conventional Bearer token follows what Google's blog calls the "finders-keepers model": whoever gets the token can use it. DPoP helps prevent this through sender-constraining: - The client generates a key pair and keeps the private key to itself. - For each request to the token endpoint, the client includes a JWT signed with its private key (a DPoP proof). - The server issues a token bound to the public key. sequenceDiagram participant C as Client (BFF) participant G as Google OAuth Note over C: Generate an EC P-256 key pair C->>G: Authorization code exchange + DPoP proof G-->>C: refresh_token (key-bound)<br/>access_token (Bearer)<br/>DPoP-Nonce Note over C: Sign each subsequent refresh proof<br/>with the same key C->>G: refresh_token grant + DPoP proof G-->>C: New access_token + new DPoP-Nonce I ran the tests against a Node.js + TypeScript server acting as a Google OAuth confidential client. For JWTs I used jose 6.2.12, which provides ES256 signing and JWK export without additional dependencies. I used a dedicated test client and account; I did not touch any production user's authorization grants. 2. Even the date of support was different from what I expected Google announced the feature on the Developers Blog on October 15, 2026. But when I checked revisions of the official documentation through the Wayback Machine, the timeline didn't quite line up. | Date | Event | | --- | --- | | April 15, 2026 | No mention of DPoP in Best Practices | | May 20, 2026 | "Sender-constrain tokens with DPoP" appeared in Best Practices | | July 31, 2026 | Last update to the DPoP Adoption Guide | | September 4, 2026 | Google's official DPoP SKILL.md committed to google/skills | | October 15, 2026 | Announcement on the Developers Blog | | October 16, 2026 | Web-server documentation updated for DPoP | Judging from the documentation, support was present by at least May—about five months before the blog announcement. I'm not suggesting anything was concealed; an official blog post's publication date is not necessarily a feature's release date. And that was just the beginning. The more I verified, the more differences I found, and they fell rather neatly into three categories. 3. Difference #1: Different from the RFC (documented design choices) RFC 9449 leaves certain optional decisions to the authorization server. Google's choices differ from the general RFC model, but these differences are documented. If you implement solely from the RFC, though, they can still trip you up. Access tokens remain Bearer tokens RFC 9449 §5 specifies DPoP as the token_type using MUST language. However, Google's web-server documentation explicitly says that token_type will always be Bearer, even when using DPoP. Only refresh tokens are bound to keys. This isn't necessarily an RFC violation. The RFC allows an exception in which only the refresh token is sender-constrained when a public client also receives a refresh token. According to Google's blog, the motivation is performance: avoid the cost of checking asymmetric signatures on every request to a high-QPS API. I measured that cost using a loop of 20,000 operations on Node v24.22.0 / Apple M4. | Operation | µs/op | ops/s | | --- | ---: | ---: | | ES256 sign | 26.2 | 38,227 | | ES256 verify | 50.5 | 19,787 | Verification took about twice as long as signing, with throughput around 20,000 verifications per second per core. At my application's scale, that overhead is negligible. At Gmail or Drive scale, it matters. Google's performance argument seems reasonable. jti isn't random during the authorization-code exchange RFC 9449 §4.2 requires the jti claim to contain at least 96 bits of pseudorandom data or a UUIDv4. Google, however, uses a deterministic value specifically during the authorization-code exchange: > the value of jti is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti = BASE64URL(SHA-256(authorization_code)) I checked this using the authorization code and jti from Google's official example: import hashlib, base64 code = "4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w" print(base64.urlsafe_b64encode(hashlib.sha256(code.encode()).digest()).rstrip(b"=").decode()) # -> o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (matches the example's jti) It matched. For refresh requests, Google instead uses an ordinary random value (192 bits). The same jti claim is generated according to different rules in different flows. Implement only one of them, and you'll run into trouble. Why? The mechanism prescribed by RFC 9449 for binding an authorization code to a key is the dpop_jkt authorization request parameter in §10. Google's authorization request doesn't include dpop_jkt. Instead, it reuses the existing jti claim. Since an authorization code is single-use, the deterministic value is effectively unique for this purpose. My reading is that Google used an existing claim instead of implementing an optional RFC mechanism. Binding tokens even for confidential clients RFC 9449 §5 says: > Refresh tokens issued to confidential clients ... are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism. Yet Google's Adoption Guide example is a confidential client that sends a client_secret. Meanwhile, Best Practices strongly recommends DPoP for public clients such as SPAs and installed apps, but recommends placing a BFF (a confidential client) in front of an SPA. You want to protect a public client, but the recommended architecture turns it into a confidential-client implementation. The RFC even notes that client authentication is more flexible than key binding, because it allows a key to be changed without invalidating the refresh token. Google's design knowingly incurs the trade-off that warning describes. 4. Difference #2: The documentation says one thing; the implementation does another Now for the main event. An official example contains an incorrectly encoded proof signature The Adoption Guide contains two sample proofs. I was planning to reuse them, but decided to verify their signatures first. That's where I got stuck. | Example | Signature length | Result | | --- | ---: | --- | | Authorization-code exchange | 64 B | SIGNATURE VALID | | Token refresh | 70 B | SIGNATURE FAILED | When I parsed the 70-byte signature as ASN.1, it parsed cleanly: hex head: 30 44 02 20 ... ASN.1 DER SEQUENCE? True | declared len: 68 r: tag=0x02 (INTEGER) len=32 s: tag=0x02 (INTEGER) len=32 JWS requires a raw R || S signature (64 bytes for P-256, in IEEE P1363 format), but the example includes an ASN.1 DER-encoded signature. Google's own web-server documentation specifically warns against doing this: > Note that JWS requires the signature to be in a raw R | S concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format. You can reproduce the trap within Node.js itself. Even for ES256, different APIs produce different signature formats: crypto.subtle.sign -> 64 bytes, first byte 0xed (raw P1363) node:crypto createSign -> 71 bytes, first byte 0x30 (ASN.1 DER) The node:crypto result was DER-encoded and 71 bytes long, while Google's example was 70 bytes. DER signature lengths vary—roughly 69–72 bytes depending on leading zeros—so don't attempt to identify DER just by checking length === 70. Another detail: when I decoded the sample proof in the web-server documentation, it turned out to be copied straight from the example in RFC 9449. Its htu is https://server.example.com/token (not Google's endpoint), its iat is 1562262616 (July 4, 2019), and its jti violates Google's own rule stated just three paragraphs earlier. Copy it verbatim, and the htu mismatch gets rejected. The documentation contradicts itself about carrying a nonce into the next workflow Can you take the DPoP-Nonce returned by an authorization-code exchange and use it for the first refresh? Two Google documents give opposite answers: - Adoption Guide (July 31, 2026): Crossing workflows causes an unconditional HTTP 400 rejection. - Web-server documentation (October 16, 2026): Reusing this nonce avoids an extra round trip. I tried it. The result was HTTP 400 use_dpop_nonce. The older Adoption Guide matched reality; the newer web-server documentation did not. The first refresh therefore requires a 400 → retry sequence, costing two round trips. A use_dpop_nonce retry loop is required, not an optimization. Without it, the first refresh immediately after authorization fails. The documented error codes don't match The web-server documentation says that a mismatched htm/htu, an expired iat, a reused proof, or an invalid signature all yield invalid_dpop_proof. My tests found otherwise: | Documentation says | Observed | Match? | | --- | --- | --- | | htu mismatch → invalid_dpop_proof | invalid_request | ❌ | | htm mismatch → invalid_dpop_proof | invalid_request | ❌ | | Expired iat → invalid_dpop_proof | invalid_dpop_proof | ✅ | | Invalid signature → invalid_dpop_proof | invalid_dpop_proof | ✅ | The observed categories were more consistent than the documentation suggests: | Condition | error | | --- | --- | | Missing proof / mismatched htm or htu | invalid_request | | Invalid signature / iat outside accepted range | invalid_dpop_proof | | Missing, expired, or cross-workflow nonce | use_dpop_nonce | | Invalid authorization grant | invalid_grant | extractable: false doesn't translate cleanly to Node.js Google's official SKILL.md calls this a "CRITICAL SECURITY GUARDRAIL": > The private key MUST be configured as non-extractable (extractable: false). But if you follow that instruction literally in Node.js, you can no longer persist the key: exportKey('jwk') -> InvalidAccessError: key is not extractable exportKey('pkcs8') -> InvalidAccessError: key is not extractable A non-extractable key can't be exported from the process. When the server restarts, every user's refresh token bound to that key becomes unusable; the only recovery is to get users to authorize again. Also, Node.js WebCrypto does not inherently provide a TPM or Secure Enclave. A non-extractable key is just a key held in process memory; Node doesn't provide the "hardware cryptographic boundary" described in SKILL.md. This wording is geared toward browsers or mobile environments, not the Node confidential-client example assumed by the Adoption Guide. In practice, you need a persistent key strategy: for example, generate with extractable: true, then protect the exported JWK using encryption or KMS. That works, but it means private-key material is now stored outside the process, potentially in your database. The "a nonce value is used only once" claim didn't hold up The Adoption Guide says: > Note that a nonce value is used only once and a missing, invalid, expired, or reused nonce value is rejected with an HTTP 400 response. First, I tested whether nonces were validated at all. Both a completely bogus nonce and a nonce altered by one character were rejected with 400 use_dpop_nonce. Validation was happening. Next, I used the same nonce repeatedly: 1st request (same nonce) 200 2nd request (same nonce) 200 3rd request (same nonce) 200 4th request (same nonce) 200 5th request (same nonce) 200 30 seconds later (same nonce) 200 All six succeeded. The nonce was validated, but not consumed. The same appeared to happen with jti. The web-server documentation says a reused jti causes invalid_dpop_proof, yet two proofs with the same jti both returned 200. I found no evidence that the server was tracking used jti values in these tests. Together, those behaviors meant I could replay an identical proof byte for byte. Sending a proof with exactly the same jti, nonce, iat, and signature repeatedly returned 200 and issued a new access token each time. I recorded SHA-256 hashes of the submitted proof and the returned tokens. Identical proof hashes resulted in different token hashes, indicating actual reissuance rather than a cached response. How long would that work? I created one proof and replayed it over time, matching the conditions of a captured proof in which both iat and nonce age together. There was no shortcut for this test; I left my terminal open for an hour. | Elapsed time since proof creation | HTTP | Result | | --- | ---: | --- | | 0–2700 s (0–45 min) | 200 | Replay accepted | | 3300 s (55 min) | 200 | Replay accepted | | 3540 s (59 min) | 200 | Replay accepted | | 3660 s (61 min) | 400 | invalid_dpop_proof | It succeeded at 59 minutes and failed at 61 minutes with invalid_dpop_proof, not use_dpop_nonce. The nonce was still accepted at 59 minutes, so the iat window appeared to expire first. In this test, a captured proof could be replayed repeatedly for approximately one hour after issuance. RFC 9449 §11.1 directly addresses this situation: > Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... As long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay. The RFC allows nonce reuse provided the server tracks jti and rejects duplicates. In my tests, Google's endpoint accepted reused nonces and didn't appear to reject repeated jti values. That means the condition attached to the RFC's nonce-reuse allowance wasn't met in the observed behavior. 5. Difference #3: Things the documentation doesn't tell you These are the details I needed for implementation but couldn't find documented. I had to measure them. The grant is validated before the proof I supplied a nonexistent refresh token while deliberately corrupting the proof in eight different ways. Even with a DER-encoded signature, a malformed typ, or simply not-a-jwt, every request returned invalid_grant and none returned DPoP-Nonce. Here's the practical consequence: a grant error can conceal bugs in your proof during debugging. If your refresh token has expired, you may only see invalid_grant, no matter how broken the proof is. This can explain the frustrating "I fixed the proof, but it still doesn't work" experience. The best debugging sequence is to verify that refresh works without DPoP first, then add DPoP. That way you can isolate the source of failure. The accepted iat window Google says expired iat values are rejected but doesn't publish an exact tolerance. RFC 9449 §4.3 leaves that detail to implementers. I narrowed it down to the second using binary search, with matching results from two independent runs. | Direction | Furthest accepted | Nearest rejected | Boundary | | --- | ---: | ---: | --- | | Past | -3599 s | -3600 s | Exactly 3600 s | | Future | +10 s | +11 s | Exactly 10 s | 3600 seconds into the past, but only 10 seconds into the future: a 360× asymmetry. A generous past window accounts for delays before a proof reaches the server. A tight future window discourages proof pre-generation, which is discussed in RFC 9449 §11.2, "DPoP Proof Pre-generation." The ~one-hour replay window from §4 was governed by the same past-side 3600-second limit. Two independently measured values pointed to the same boundary. One practical lesson: a server clock just 11 seconds ahead can cause every proof to be rejected. If you're seeing unexplained invalid_dpop_proof errors on a machine without working NTP synchronization, check its clock. Other undocumented details - The challenge field in successful responses. It appears in an Adoption Guide example but isn't explained in the prose. Its value matched the DPoP-Nonce header. - No DPoP advertisement in discovery. RFC 9449 §5.1 defines authorization-server metadata named dpop_signing_alg_values_supported. When I queried accounts.google.com/.well-known/openid-configuration, I found no keys containing dpop. A standards-based client relying on discovery for feature detection cannot discover Google's DPoP support this way. - The algorithm name for RSA. The Adoption Guide allows RSA but only says it's not recommended because of computational cost. It doesn't specify RS256 versus PS256, and discovery doesn't list either. You have to try it to find out. 6. I reported it, and the result was Won't Fix I reported the last issue from §4—no apparent jti tracking, nonce reuse, and replayable proofs—to Google Bug Hunters. It conflicted with the documentation and appeared to miss a condition described in the RFC's replay-hardening guidance. I attached reproduction logs with secrets replaced by irreversible hashes. The result was Won't Fix (Infeasible). Google's reasoning, in summary: - I hadn't demonstrated account takeover, privilege escalation, or an independent authentication bypass. - I had acknowledged that the practical impact appeared limited. - The report didn't meet the Vulnerability Reward Program threshold of a meaningful compromise to another user's data confidentiality or integrity. I agree with that assessment. An attacker in a position to observe the proof (for example, through an adversary-in-the-middle setup, a TLS-terminating proxy, or logs) could also capture the refresh_token and client_secret from the same request body. And as shown in §5, a proof alone is rejected with invalid_grant; replay requires a valid refresh_token. The replay window did not establish an additional attacker capability in the scenario I tested. In that sense, the response helped clarify the nature of the finding: an observed discrepancy between implementation and documentation, not a demonstrated exploitable security vulnerability. That is the core message of this article. It's still worth documenting. Statements like "a nonce value is used only once" and "a reused jti is rejected" lead implementers to expect behavior that I couldn't reproduce. You might implement unnecessary synchronization—or rely on a replay defense that isn't behaving as documented. The report and its outcome are another reminder not to treat official documentation as a substitute for testing. 7. Should you adopt DPoP? DPoP binding is valuable when you store refresh tokens long-term and want to reduce the risks of that storage being compromised. If a database dump or log leak exposes only a refresh token, an attacker cannot use it without the corresponding key. That's a meaningful benefit. Its protection is narrower than it might seem, though. Access tokens remain Bearer tokens and can still be used until they expire. Google's blog explicitly says that browser session cookies are outside DPoP's scope, pointing instead to DBSC for that problem. As discussed in §4, on Node.js the private key still needs to be persisted somewhere, weakening the simplistic assumption that "the key never leaves the device." The biggest adoption cost is migration. To move existing users with unbound refresh tokens to DPoP-bound refresh tokens, you need to revoke the old grant or get a new token using prompt=consent. For a running service, that means asking every existing user to consent again. Key rotation introduces the same issue: obtaining a refresh token bound to a new key requires reauthorization. In other words, your private-key backup strategy effectively becomes your refresh-token backup strategy. 8. Conclusion Google's DPoP implementation works. Its design choices have rational motivations, and my measurements supported its performance argument for leaving access tokens as Bearer tokens. The difficulties all came from documentation gaps: differences from the RFC that are documented (§3), claims that don't match observed behavior (§4), and details that aren't documented at all (§5). The most eye-catching finding—the "used only once" claim—was reported and closed as Won't Fix, reinforcing that I had uncovered a documentation discrepancy rather than demonstrated a security vulnerability (§6). Here are the implementation details I wish the docs had told me: - The authorization-code jti is BASE64URL(SHA-256(code)), while a refresh uses a random value. Different flows, different rules. - The first refresh triggers use_dpop_nonce before succeeding. You need a retry loop. - Mismatched htm/htu returns invalid_request, not invalid_dpop_proof. - iat acceptance spans 3600 seconds into the past and 10 seconds into the future. Clock skew toward the future can break every proof. - In a Node.js process, extractable: false isn't compatible with exporting a key for persistent storage. Plan key persistence first. - Verify refresh works without DPoP before adding DPoP; otherwise, proof errors can be hidden behind invalid_grant. References - DPoP Adoption Guide — Google's DPoP implementation guide; proof construction and nonce handling. - Using OAuth 2.0 for Web Server Applications — Token endpoint parameters, error codes, and notes on prompt=consent. - OAuth 2.0 Best Practices — Recommendations on sender-constraining. - Announcing DPoP Support for Google OAuth — Google's announcement, performance trade-offs, and threat model. - RFC 9449 — DPoP specification; §5 token types, §10 dpop_jkt, §11 security considerations. - google/skills — dpop-adoption — Google's implementation skill; non-extractable keys and signature formats.
Google OAuthがDPoPに対応したらしいので検証してみたら...なんか違った > 実装は、ちゃんと動きました。壊れていたのはドキュメントの方です。 この記事の結論 Google OAuth の Refresh Token を DPoP で鍵にバインドする仕組みを、公式ドキュメント・RFC 9449・自分の計測・実際のトークン交換の 4 方向から検証しました。実装はまともに動きます。詰まったところは、1 つ残らず「ドキュメントとの差分」でした。 差分は 3 種類に分かれます。 - RFC と違う — Access Token は Bearer のまま、jti が乱数ではない。設計判断で、ドキュメントには書いてあります - 書いてあることと、実際が違う — 公式サンプルの署名が壊れている、ドキュメント同士が矛盾している、"a nonce value is used only once" が嘘 - そもそも書いていない — iat の許容範囲、grant と proof の検証順。実測するしかありませんでした 実測した環境です。 | 項目 | 値 | | --- | --- | | 検証日 | 2026-10-19 | | Node.js | v24.22.0 | | jose | 6.2.12 | | hono | 4.13.13 | | マシン | Apple M4 (arm64) | 実装はこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 Google の DPoP 実装そのものは、おかしくありません。 設計判断にはちゃんと理由があり、性能を理由にした主張も測ってみれば妥当でした。それでも実装者が詰まるのは、書いてあることが実際と違ったり、肝心なことが書いていなかったりするからです。最後に見つけた "used only once" の件は Google に報告しましたが、Won't Fix で返ってきました。つまりそれも、仕様の問題ではなく記述の問題だったということです。 > この記事の対象は、Google API 連携で Refresh Token をサーバーに保存している開発者です。 --- 1. DPoP とは何か DPoP(Demonstrating Proof-of-Possession)は RFC 9449 で標準化された OAuth 2.0 のセキュリティプロファイルです。2023 年 9 月に Proposed Standard になっていて、Okta や FusionAuth はずっと前から対応しています。新しいのは Google が対応したことだけで、DPoP 自体は新登場ではありません。 通常の Bearer Token は、Google のブログの表現を借りると "finders-keepers model" です。拾った人が使えてしまう。DPoP はこれを送信者拘束(sender-constraining)で防ぎます。 - クライアントが鍵ペアを生成し、秘密鍵を自分だけが持つ - トークンエンドポイントへのリクエストごとに、秘密鍵で署名した JWT(DPoP proof)を添える - サーバーはトークンを公開鍵に紐づけて発行する sequenceDiagram participant C as クライアント (BFF) participant G as Google OAuth Note over C: EC P-256 の鍵ペアを生成 C->>G: 認可コード交換 + DPoP proof G-->>C: refresh_token(鍵にバインド)<br/>access_token(Bearer)<br/>DPoP-Nonce Note over C: 以降 Refresh のたびに<br/>同じ鍵で proof を署名 C->>G: refresh_token grant + DPoP proof G-->>C: 新しい access_token + 新しい DPoP-Nonce 検証は Node.js + TypeScript のサーバー(Google OAuth の Confidential Client)で行いました。JWT ライブラリは jose 6.2.12 で、依存ゼロで ES256 署名と JWK エクスポートが揃います。専用のテストクライアントとテストアカウントを使い、本番ユーザーの grant には触れていません。 2. まず「いつ対応したのか」が違った 発表は 2026-10-15 の Google Developers Blog です。ところが Wayback Machine で公式ドキュメントの改版を追うと、話が合いません。 | 日付 | 出来事 | | --- | --- | | 2026-04-15 | Best Practices に DPoP の記述はゼロ | | 2026-05-20 | Best Practices に "Sender-constrain tokens with DPoP" が登場 | | 2026-07-31 | DPoP Adoption Guide の最終更新 | | 2026-09-04 | google/skills に公式の DPoP SKILL.md が commit | | 2026-10-15 | Developers Blog で発表 | | 2026-10-16 | web-server doc が DPoP 対応で更新 | ドキュメント上は、少なくとも 5 月には対応が入っていたことになります。発表はその約 5 ヶ月後です。隠していたという話ではなく、公式ブログの日付は機能のリリース日とは限らない、というだけの話です。 そしてこれは序の口でした。確かめるほど差分が出てきて、しかもきれいに 3 種類に分かれます。 3. 差分①:RFC と違う(仕様。ちゃんと書いてある) RFC 9449 はオプションの判断を各認可サーバーに委ねています。Google の選択は RFC の一般仕様とズレますが、これは「書いてある」類の差分です。RFC だけ読んで実装すると、ここで詰まります。 Access Token は Bearer のまま RFC 9449 §5 は token_type に DPoP を MUST としていますが、web-server doc は「token_type は DPoP を使っても常に Bearer」と明言しています。バインドされるのは Refresh Token だけです。 ただしこれは RFC 違反ではありません。RFC 側に逃げ道があって、public client が Refresh Token も受け取る場合に「Refresh Token だけをバインドする」ことを明示的に許容しています。 理由はブログによると性能で、高 QPS の API で毎回非対称署名を検証するコストを避けたい、とのことです。これは測ってみました。Node v24.22.0 / Apple M4 で 20,000 回のループです。 | 操作 | µs/op | ops/s | | --- | ---: | ---: | | ES256 sign | 26.2 | 38,227 | | ES256 verify | 50.5 | 19,787 | 検証は署名の約 2 倍、1 コアあたり毎秒 2 万回程度でした。自分のアプリの規模なら誤差ですが、Gmail や Drive の規模なら効きます。 Google の言い分は妥当だと思います。 認可コード交換の jti が乱数ではない RFC 9449 §4.2 は jti に「96 bit 以上の擬似乱数か UUIDv4」を要求します。ところが Google は、認可コード交換のときだけ決定的な値を使います。 > the value of jti is the Base-64 and Url-encoded SHA256 hash of the authorization code, such as jti = BASE64URL(SHA-256(authorization_code)) 公式サンプルに載っている認可コードと jti で、実際に計算して確かめました。 import hashlib, base64 code = "4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w" print(base64.urlsafe_b64encode(hashlib.sha256(code.encode()).digest()).rstrip(b"=").decode()) # -> o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck (サンプルの jti と一致) 一致しました。Refresh のときは逆に普通のランダム値(192 bit)を使います。同じ jti クレームが、フローによって別のルールで決まるので、片方だけ実装すると必ず詰まります。 なぜこうなっているのか。RFC が定める「認可コードを鍵に縛る」方法は §10 の dpop_jkt という認可リクエストパラメータですが、Google の認可リクエストに dpop_jkt はありません。代わりに既存の jti クレームを流用しています。認可コードは一度しか使えないので、決定的な値でも実質的に一意になり、目的は達成できます。RFC の OPTIONAL な機能を実装する代わりに、手持ちのクレームで代替した、と読めます。 Confidential Client なのにバインドする RFC 9449 §5 はこう書いています。 > Refresh tokens issued to confidential clients ... are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism. ところが Google の Adoption Guide のサンプルは client_secret を送る Confidential Client です。一方で Best Practices が DPoP を強く勧めるのは SPA やインストール型アプリといった public client で、その SPA には「BFF(= Confidential Client)を立てろ」と言います。public client を守りたいのに、実装は Confidential Client になるというねじれです。 RFC は「鍵に縛るより client 認証の方が柔軟(鍵を変えても Refresh Token を失効させずに済む)」とまで書いていて、Google の構成はその注意点をあえて踏みにいっています。 4. 差分②:書いてあることと、実際が違う ここからが本題です。 公式サンプルの proof は、署名が壊れている Adoption Guide には proof のサンプルが 2 つ載っています。そのまま拝借するつもりでしたが、念のため動かす前に署名だけ検証しました。そこで止まりました。 | サンプル | 署名長 | 検証結果 | | --- | ---: | --- | | 認可コード交換 | 64 B | SIGNATURE VALID | | Token Refresh | 70 B | SIGNATURE FAILED | 70 バイトの中身を ASN.1 としてパースすると、きれいに通りました。 hex head: 30 44 02 20 ... ASN.1 DER SEQUENCE? True | declared len: 68 r: tag=0x02 (INTEGER) len=32 s: tag=0x02 (INTEGER) len=32 JWS が要求するのは raw な R || S(P-256 なら 64 バイト、IEEE P1363 形式)ですが、サンプルは ASN.1 DER のまま貼られています。これは Google 自身が web-server doc で「やるな」と書いていることそのものです。 > Note that JWS requires the signature to be in a raw R | S concatenated format (64 bytes for P-256). If using OpenSSL directly, you must convert the default ASN.1 DER-encoded signature to this raw format. この罠は Node の中でも再現できます。同じ ES256 署名でも、API によって出力形式が違います。 crypto.subtle.sign -> 64 bytes, first byte 0xed (raw P1363) node:crypto createSign -> 71 bytes, first byte 0x30 (ASN.1 DER) node:crypto 側は DER で、しかも 71 バイトです。サンプルは 70 バイトでした。DER の長さは leading zero 次第で 69〜72 バイトに揺れるので、length === 70 のような長さチェックで DER を判定してはいけません。 もう 1 つ。web-server doc のサンプル proof をデコードすると、RFC 9449 の例文の丸写しでした。htu が https://server.example.com/token(Google のエンドポイントではない)、iat は 1562262616(2019-07-04)で、jti も同じページの 3 段落上に書いてある自分のルールを破っています。そのままコピーすると htu 不一致で弾かれます。 Nonce の持ち越し可否で、ドキュメント同士が矛盾している 認可コード交換で受け取った DPoP-Nonce を、初回の Refresh にそのまま使えるか。ここで新旧 2 つのドキュメントが逆のことを書いています。 - Adoption Guide(2026-07-31):workflow をまたぐと「無条件に 400 で拒否する」 - web-server doc(2026-10-16):この nonce を使えば「余分な往復を避けられる」 実際に投げたら HTTP 400 use_dpop_nonce でした。古い Adoption Guide の方が正しく、新しい web-server doc が間違っています。 つまり初回 Refresh は必ず 400 → リトライで 2 往復になります。use_dpop_nonce のリトライループは最適化ではなく必須で、省くと認可直後の 1 回目が必ず失敗します。 エラーコードの説明が違う web-server doc は「htm/htu 不一致、iat 期限切れ、再利用、署名不正はすべて invalid_dpop_proof」と書いていますが、実測は違いました。 | doc の主張 | 実測 | | | --- | --- | --- | | htu 不一致 → invalid_dpop_proof | invalid_request | ❌ | | htm 不一致 → invalid_dpop_proof | invalid_request | ❌ | | iat 期限切れ → invalid_dpop_proof | invalid_dpop_proof | ✅ | | 署名不正 → invalid_dpop_proof | invalid_dpop_proof | ✅ | 実際の切り分けはこうなっていて、こちらの方がドキュメントの記述より一貫しています。 | 状況 | error | | --- | --- | | proof が無い / htm・htu が噛み合わない | invalid_request | | 署名が不正 / iat が範囲外 | invalid_dpop_proof | | nonce が無い・期限切れ・workflow 跨ぎ | use_dpop_nonce | | grant 自体が不正 | invalid_grant | extractable: false は、Node には当てはまらない Google 公式の SKILL.md は、秘密鍵の扱いを「CRITICAL SECURITY GUARDRAIL」としてこう要求します。 > The private key MUST be configured as non-extractable (extractable: false). ところが Node.js でこれを字義通り守ると、鍵を永続化できなくなります。 exportKey('jwk') -> InvalidAccessError: key is not extractable exportKey('pkcs8') -> InvalidAccessError: key is not extractable 非 extractable な鍵はプロセスの外に出せません。サーバーが再起動した瞬間、その鍵にバインドされた全ユーザーの Refresh Token が使用不能になり、復旧手段は再同意しかありません。 さらに、Node.js の WebCrypto には TPM も Secure Enclave もありません。非 extractable 鍵はただのプロセスメモリ上の鍵で、SKILL.md が言う「hardware cryptographic boundary」は Node には存在しません。この文言はブラウザやモバイル向けで、Adoption Guide 自身が前提にしている Node の Confidential Client には当てはまらないのです。 現実的には extractable: true で生成し、書き出した JWK を暗号化や KMS で保護して保存することになります。動きますが、その瞬間に秘密鍵成分が DB に乗ります。 "a nonce value is used only once" が、嘘だった Adoption Guide はこう書いています。 > Note that a nonce value is used only once and a missing, invalid, expired, or reused nonce value is rejected with an HTTP 400 response. まず「そもそも nonce は検証されているのか」の対照を取りました。でたらめな nonce と 1 文字変えた nonce は、どちらも 400 use_dpop_nonce で弾かれます。検証はされています。 そのうえで、同じ nonce を続けて使いました。 1 回目(同じ nonce) 200 2 回目(同じ nonce) 200 3 回目(同じ nonce) 200 4 回目(同じ nonce) 200 5 回目(同じ nonce) 200 30 秒後(同じ nonce) 200 6 回とも通りました。 検証はされるが、消費はされません。 同じことが jti でも起きます。web-server doc は「再利用された jti は invalid_dpop_proof」と書いていますが、同じ jti を持つ proof を 2 回投げても両方 200 でした。jti は記録されていません。 この 2 つが重なると、proof をバイト単位でそのまま再利用できます。 jti・nonce・iat・署名まで完全に同一の proof を投げ直すと、繰り返し 200 が返り、そのたびに新しい Access Token が発行されました(送った proof の SHA-256 と、返ってきたトークンのハッシュを記録しています。同じ proof から毎回違うトークンが出る=キャッシュではなく本当に再発行されている、ということです)。 いつまで再利用できるのか。1 本の proof を作って、現実に盗まれた proof と同じ条件(iat も nonce も一緒に古くなる)で、時間をおいて投げ直しました。ここだけは早送りができないので、ターミナルを開いたまま 1 時間待ちました。 | 経過(proof 作成から) | HTTP | 結果 | | --- | ---: | --- | | 0 〜 2700 秒(0〜45 分) | 200 | 再利用成立 | | 3300 秒(55 分) | 200 | 再利用成立 | | 3540 秒(59 分) | 200 | 再利用成立 | | 3660 秒(61 分) | 400 | invalid_dpop_proof | 59 分では通り、61 分で invalid_dpop_proof(use_dpop_nonce ではない)で落ちました。nonce は 59 分時点でもまだ有効だったので、先に来たのは iat の窓です。捕捉した proof は、発行からおよそ 1 時間、何度でも再利用できます。 ここで RFC 9449 §11.1 を読み直すと、まさにこの条件が書いてあります。 > Unlike cryptographic nonces, it is acceptable for clients to use the same nonce multiple times ... As long as the jti value is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay. nonce の使い回しを許していいのは、jti を追跡している場合に限る。Google は jti を追跡しないまま、nonce 再利用だけを許しています。RFC が条件付きで認めた許可の、条件の方を満たしていない状態です。 5. 差分③:そもそも書いていない 実装していて必要になったのに、どこにも書かれていなかったものです。実測するしかありませんでした。 grant は proof より先に検証される 存在しない Refresh Token を送りつつ、proof の方だけを 8 通りに壊してみました。DER 署名の proof でも、typ を壊しても、not-a-jwt を送っても、全部 invalid_grant で、DPoP-Nonce は一度も返りませんでした。 ここから実装上の教訓が出ます。デバッグ中に proof のバグが invalid_grant に隠れるのです。Refresh Token が期限切れだと、proof がどれだけ壊れていても invalid_grant しか返りません。「proof を直したのに直らない」の原因がここにあることがあります。 実装の順序としては「まず DPoP なしで Refresh が通ることを確認してから DPoP を足す」が正解になります。どちらが壊れているのか切り分けられるからです。 iat の許容範囲 Google は「期限切れの iat は弾く」としか書いておらず、秒数を公開していません。RFC 9449 §4.3 も実装依存としています。二分探索で 1 秒まで詰めました(独立した 2 回の実行で一致)。 | 方向 | 通る最遠 | 弾かれる最近 | 値 | | --- | ---: | ---: | --- | | 過去 | -3599 s | -3600 s | ちょうど 3600 秒 | | 未来 | +10 s | +11 s | ちょうど 10 秒 | 過去は 3600 秒、未来は 10 秒。360 倍の非対称です。 過去側が緩いのは proof が届くまでの遅延を見込むため、未来側が厳しいのは未来の iat が「proof の作り置き」を意味するからで、RFC 9449 §11.2 "DPoP Proof Pre-generation" が警戒しているのがこれです。なお §4 で見た「proof を約 1 時間再利用できる」の窓を決めていたのは、この過去側の 3600 秒でした。別々に測った 2 つの数字が同じ値を指したことになります。 実装側の教訓としては、サーバーの時計が未来に 11 秒ずれているだけで全 proof が弾かれます。NTP が効いていない環境で「なぜか invalid_dpop_proof」が出るときは、ここを疑う価値があります。 その他、書かれていなかったもの - 成功レスポンスの challenge フィールド。 Adoption Guide のサンプルには出てきますが、本文に説明がありません。値は DPoP-Nonce ヘッダーと同一でした - discovery に DPoP の宣言がない。 RFC 9449 §5.1 は dpop_signing_alg_values_supported という authorization server metadata を定義していますが、accounts.google.com/.well-known/openid-configuration を引いても dpop を含むキーは 1 つもありません。discovery ベースで feature detection する標準準拠のクライアントは、Google の DPoP 対応を検出できません - RSA を使う場合の alg 名。 Adoption Guide は RSA も可としますが「計算コストが高いので非推奨」とだけ書かれていて、RS256 なのか PS256 なのかは書かれていません。discovery にも無いので、投げて試すしかありません 6. 報告したら Won't Fix だった §4 の最後の件(jti 未追跡 + nonce 再利用 + proof の再利用)は、ドキュメントとの食い違いであると同時に RFC の hardening 条件も満たしていないので、Google の Bug Hunters に報告しました。再現用の生ログ(秘密は不可逆なハッシュに置き換えたもの)を添えています。 結果は Won't Fix(Infeasible) でした。要旨はこうです。 - アカウント乗っ取り・権限昇格・認証バイパスが示されていない - 報告者自身が「実用上の影響は限定的」と認めている - 「他ユーザーの機微データの機密性・完全性を実質的に損なう」という VRP の基準を越えていない この判断には納得しています。proof を観測できる立場(AiTM、TLS 終端プロキシ、ログ)なら、同じリクエストボディに入っている refresh_token と client_secret も一緒に取れます。しかも §5 のとおり proof 単体では invalid_grant で弾かれるので、再利用には有効な refresh_token が必須です。再利用できる窓があっても、攻撃者の能力を増やしていません。 逆に言うと、この回答で finding の性質が確定しました。セキュリティバグではなく、ドキュメントと実装の食い違いである、と Google 自身が位置づけたということです。この記事の主張そのものです。 それでも書く価値はあると思っています。"a nonce value is used only once" や「再利用された jti は弾く」は、読んで実装すると現実と違うので、その通りに作れば要らない排他制御を書いたり、効いていない前提に寄りかかったりします。報告して「追跡しない」と返ってきた結末まで含めて、公式の記述を鵜呑みにしない材料になるはずです。 7. で、入れるべきなのか DPoP バインドが効くのは、Refresh Token を長期保存していて、その保存先が漏れるリスクを下げたいケースです。DB ダンプやログ流出で Refresh Token だけが漏れても、鍵がなければ再利用されません。これは確かな価値です。 ただし守備範囲は見かけより狭いです。Access Token は Bearer のままなので有効期限内は使われますし、ブラウザのセッション Cookie は対象外だとブログが明言しています(その領域は DBSC が担当する、という整理です)。そして §4 のとおり Node では秘密鍵を結局どこかに保存することになるので、「鍵は外に出ない」という前提も一段弱くなります。 導入コストで一番重いのは移行です。すでに Refresh Token を持っているユーザーを DPoP バインドに切り替えるには、既存の grant を revoke するか prompt=consent で再発行させるしかありません。稼働中のサービスでは全ユーザーに再同意を踏ませることになります。鍵をローテーションしたい場合も同じで、新しい鍵にバインドされた Refresh Token を得るには再認可が必要です。つまり鍵のバックアップ戦略が、そのまま Refresh Token のバックアップ戦略になります。 8. まとめ Google の DPoP 実装は、動きます。仕様の選択にも理由があり、Bearer のままにした性能上の判断は測ってみれば妥当でした。 詰まったのは全部、ドキュメントとの差分でした。RFC と違うところ(§3)、書いてあることと実際が違うところ(§4)、そもそも書いていないところ(§5)。一番派手だった "used only once" は報告しましたが、Won't Fix で返ってきて、やはり記述の問題だったことがはっきりしました(§6)。 ドキュメントを読むだけでは出てこない点を置いておきます。 - 認可コード交換の jti は BASE64URL(SHA-256(code))、Refresh は乱数。フローで別ルール - 初回 Refresh は必ず use_dpop_nonce で 1 回落ちる。リトライループは必須 - htm/htu の不一致は invalid_dpop_proof ではなく invalid_request - iat は過去 3600 秒・未来 10 秒。時計が未来にずれると全滅する - Node では extractable: false にできない。鍵の保存方法を先に決める - まず DPoP なしで Refresh を通してから DPoP を足す。でないと invalid_grant に隠れる 参考 - DPoP Adoption Guide — Google の DPoP 実装ガイド。proof の構成と Nonce の扱い - Using OAuth 2.0 for Web Server Applications — トークンエンドポイントのパラメータとエラー、prompt=consent の注記 - OAuth 2.0 Best Practices — sender-constraining の推奨対象 - Announcing DPoP Support for Google OAuth — 発表ブログ。性能トレードオフと脅威モデル - RFC 9449 — DPoP の仕様。§5 token_type、§10 dpop_jkt、§11 セキュリティ考慮 - google/skills — dpop-adoption — 公式の実装スキル。非 extractable 鍵と署名形式