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
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
jtiisn'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
iatwindow 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
jtiis the Base-64 and Url-encoded SHA256 hash of the authorization code, such asjti = 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 | Sconcatenated 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
jtivalue 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
challengefield in successful responses. It appears in an Adoption Guide example but isn't explained in the prose. Its value matched theDPoP-Nonceheader. -
No DPoP advertisement in discovery. RFC 9449 Β§5.1 defines authorization-server metadata named
dpop_signing_alg_values_supported. When I queriedaccounts.google.com/.well-known/openid-configuration, I found no keys containingdpop. 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
jtiisBASE64URL(SHA-256(code)), while a refresh uses a random value. Different flows, different rules. - The first refresh triggers
use_dpop_noncebefore succeeding. You need a retry loop. - Mismatched
htm/htureturnsinvalid_request, notinvalid_dpop_proof. -
iatacceptance 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: falseisn'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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.