Google OAuth Refresh Tokens Now Die Without Their Private Key

Bearer tokens are passwords you can copy. Google just turned its refresh tokens into locks you have to physically own, binding each to a private key with DPoP—and making token theft useless without the…

Here’s the 2 a.m. version of the problem. A laptop gets popped, an infostealer rakes the token cache, and six weeks later a refresh token you forgot existed is quietly minting access tokens from an IP in a country your users have never visited. Bearer tokens are called bearer tokens for a reason: whoever bears them wins. For the better part of a decade that has been the whole game.

Google just changed the rules for its own token endpoint. Here’s how we got here.

September 2023 — RFC 9449 lands

The IETF publishes Demonstrating Proof of Possession. The idea is almost insultingly simple: the client generates a key pair, keeps the private key, and ships the public key to the authorization server when it asks for a token. The server stamps a thumbprint of that key (cnf.jkt) into the token. From then on, every request has to carry a fresh signed proof that you still hold the matching private key. No key, no proof, no token. The token stops being a password you can copy and becomes a lock you have to physically own.

The mechanic — a signed JWT per request

The proof is a short-lived JWT in a DPoP header. Header carries typ: dpop+jwt and the public JWK. Body binds the proof to this exact call: htm (method), htu (URL), iat, a random jti, and ath (a hash of the access token) once you’re hitting a resource. Replay it against a different endpoint and the signature check fails. That’s the part people miss — it’s not one key operation, it’s one per request.

October 2026 — Google turns it on

Google now accepts DPoP at oauth2.googleapis.com/token and binds the issued refresh token to your key. You send the proof on the authorization-code exchange and on every refresh. The response comes back "token_type": "DPoP" instead of Bearer, which is your signal the binding took. The refresh exchange looks like this:

request.httpHTTP
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiLi4uIiwieSI6Ii4uLiJ9fQ.eyJqdGkiOiJ4OTJhIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwiaWF0IjoxNzkxMjM0NTY3fQ.MEUCIQ...
​
grant_type=refresh_token&refresh_token=1//0gabc...&client_id=...&client_secret=...

Decoded, that proof is nothing exotic:

response.jsonJSON
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
{
  "jti": "x92a",
  "htm": "POST",
  "htu": "https://oauth2.googleapis.com/token",
  "iat": 1791234567
}

ES256 keys, P-256 curve. Generate once, store the private key somewhere malware can’t trivially scrape — a TPM, a secure enclave, a KMS. If you leave it in the same config.json as the refresh token, you’ve built a very elaborate way to achieve nothing. The whole security argument collapses the moment both halves live in the same stealer-readable file.

What it does not fix

DPoP kills exfiltration and replay. It does nothing about phishing. If a user is tricked into authorizing your attacker’s app, that app holds the private key legitimately and DPoP waves it right through. It also doesn’t stop token theft on a box the attacker already fully controls — if they can sign proofs on the live machine, binding is cosmetic. It raises the cost from “copy a string” to “maintain persistent code execution on the endpoint,” which is a real and meaningful jump, not a magic ward.

Migration — opt-in, and it stays that way

Nothing breaks. Existing integrations keep getting bearer tokens because they don’t send a DPoP header, and Google keeps issuing them. You adopt it per client, at your pace: generate a key, attach the proof, start handling 401 responses carrying WWW-Authenticate: DPoP with a nonce you must echo back in your next proof’s nonce claim. That nonce dance is the bit that trips first implementations — Google can demand a server-chosen nonce, and if you don’t replay it, every call 401s in a loop. Build the retry before you ship, not after the pager goes off.

The next dated beat isn’t on Google’s calendar. It’s on yours — the day you audit which of your service integrations still hand out tokens that work from anywhere on earth, and decide how many more 2 a.m. incidents you’re willing to underwrite before you rotate them onto a key.