Back to Blog
DPoP for OAuth: A Practical Implementation Guide
Security
12 min read
14 views

DPoP for OAuth: A Practical Implementation Guide

System Admin

November 2, 2026

The token that works from anywhere

A refresh token is a password that never changes until it expires. If malware on a laptop reads it out of a config file or a browser profile, the attacker can trade it for access tokens from their own machine, in their own country, and your server has no way to tell the difference. That is the problem DPoP (Demonstrating Proof-of-Possession) is designed to shrink.

Google's OAuth platform announced DPoP support to sender-constrain refresh tokens. The framing in that announcement is specific: refresh tokens get cryptographically bound to a client-held key, while short-lived access tokens stay as standard Bearer tokens. That second half is the part people skim past. It means your hot API paths do not change.

A note on scope: Google's announcement is a summary. It does not describe nonce behavior, migration, or endpoint specifics. The implementation details below come from RFC 9449, not from Google's post. Where I describe a migration approach, that is my own design, not a documented Google procedure.

This article walks through what you actually build: key generation and storage, the proof JWT, validation with nonce and replay protection, and a generic approach to migrating refresh tokens you already issued. It also covers where the scheme gets awkward, because it does.

What sender-constrained actually means

A normal OAuth access token is a Bearer token. The name is literal: whoever bears it, wins. The resource server checks that the token is valid and unexpired, and that is the whole check. It cannot know whether the caller is the client the token was issued to.

DPoP adds a second factor that is not a shared secret. The client generates a key pair and keeps the private key. When it calls the token endpoint or a protected resource, it attaches a DPoP proof: a small JWT signed with that private key. The server checks that the proof matches the request and that the public key in the proof matches the key the token was bound to at issuance.

So a stolen refresh token is not enough. The attacker also needs the private key. If the key lives in a hardware-backed keystore, extracting it is a different class of attack than copying a string out of a file.

That is the mechanism. Whether it helps depends entirely on where the key lives, which we will get to.

Step one: generate and store the key

The client key is usually an asymmetric key pair. ES256 (ECDSA P-256) is a common choice in practice because it is broadly supported across JOSE libraries and produces compact signatures. RFC 9449 registers ES256, ES384, ES512, RS256, and PS256 for DPoP proofs; Ed25519 is not among them and will not interoperate with conformant servers. The private key never leaves the client. The public key travels inside every proof.

Here is a minimal Node.js module that generates a key pair and stores it. Drop it into a module; it writes the key to disk, which is fine for a server-side client and wrong for a laptop, as discussed below. The example uses jose v5 and deliberately uses an extractable key because it is file-stored; that is the opposite of what you want in a browser, where you should prefer a non-extractable key.

๐ŸŸจJavaScript
import { generateKeyPair, exportJWK, importJWK } from 'jose';
import { writeFile, readFile } from 'node:fs/promises';

const KEY_PATH = './dpop-private.jwk';

export async function loadOrCreateKey() {
  try {
    const raw = await readFile(KEY_PATH, 'utf8');
    const { privateJwk, publicJwk } = JSON.parse(raw);
    const privateKey = await importJWK(privateJwk, 'ES256');
    return { privateKey, publicJwk };
  } catch (err) {
    if (err.code !== 'ENOENT') throw err;
    const { privateKey, publicKey } = await generateKeyPair('ES256', {
      extractable: true,
    });
    const privateJwk = await exportJWK(privateKey);
    const publicJwk = await exportJWK(publicKey);
    // Store the private JWK and the public JWK separately.
    await writeFile(
      KEY_PATH,
      JSON.stringify({ privateJwk, publicJwk }),
      { mode: 0o600 }
    );
    return { privateKey, publicJwk };
  }
}

The jose library handles the JOSE (JSON Object Signing and Encryption) plumbing. Requires jose v5+; on v4 the API differs. Note the 0o600 file mode: on a server, the key file should be readable only by the process that owns it. On a developer laptop, this file is exactly what an infostealer would grab, which is why the storage decision matters more than the crypto.

Step two: build the proof JWT

The proof is a JWT with a specific shape. The header carries the public key in JWK form and the type dpop+jwt. The claims carry the HTTP method, the URL, a unique ID, and a timestamp.

๐ŸŸจJavaScript
import { SignJWT } from 'jose';
import { randomUUID, createHash } from 'node:crypto';

export async function makeProof(privateKey, publicJwk, method, url, opts = {}) {
  const claims = {
    htm: method.toUpperCase(),
    htu: url,
    jti: randomUUID(),
    iat: Math.floor(Date.now() / 1000),
  };
  if (opts.nonce) claims.nonce = opts.nonce;
  if (opts.accessToken) {
    // RFC 9449 requires ath when the proof accompanies an access token.
    claims.ath = createHash('sha256')
      .update(opts.accessToken)
      .digest('base64url');
  }

  return new SignJWT(claims)
    .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk })
    .sign(privateKey);
}

The htu claim is the target URI and htm is the method. The server must compare both against the actual request, otherwise an attacker can replay a proof made for a harmless endpoint against a sensitive one. The ath claim is the base64url SHA-256 of the access token. RFC 9449 requires ath on resource-server proofs that carry an access token; it is not used on token-endpoint proofs. Do not generalize beyond that. A validator that skips ath is vulnerable to a proof being paired with a different token.

Send it as a header: DPoP: <proof>. When you call the token endpoint, the refresh token goes in the body as usual and the proof goes in the header.

Step three: validate on the resource server

Validation is where most implementations get sloppy. A correct check does several things in order, and short-circuits on the first failure.

  1. Parse the JWT and confirm the header typ is dpop+jwt and the algorithm is one you allow. Reject none and symmetric algorithms.
  2. Extract the public key from the jwk header. Do not trust a key ID; use the embedded key.
  3. Verify the signature against that key.
  4. Check htm equals the request method and htu equals the request URL, after normalizing scheme, host, path, and query per your policy.
  5. Check iat is within an acceptable window.
  6. Check jti has not been seen before within the window (see below for the stateless alternative).
  7. If the request carries an access token, verify ath equals base64url(SHA-256(access token)).
  8. If the token is sender-constrained, confirm the key in the proof matches the key the token was bound to at issuance.

The jti check is the most reliable way to detect replay, but it forces shared state. A nonce plus a tight iat window is a stateless alternative that limits, but does not eliminate, replay. A small in-memory set works for a single process and fails the moment you run more than one. For a real deployment, put the jti in a shared store with a TTL equal to your acceptance window. Redis with SET dpop:jti:<thumbprint>:<jti> 1 NX EX <TTL> is one pattern, where <TTL> should equal your configured iat acceptance window plus clock skew; 300 seconds is an example only. Treat a failed NX as a replay rejection, not a silent no-op, and namespace the key by the proof's key thumbprint so two clients cannot collide.

Nonce handling is the other piece. A server may respond to a proof with a DPoP-Nonce header and an error, asking the client to retry with that nonce embedded. Concretely, per RFC 9449: for a token-endpoint request, the server responds with HTTP 400 and error: use_dpop_nonce; for a resource-server request, it responds with HTTP 401 and WWW-Authenticate: DPoP error="use_dpop_nonce". In both cases a DPoP-Nonce header is included. The client caches the nonce, rebuilds the proof with the nonce claim set to that value, and retries. Servers may rotate nonces, so a single retry is not guaranteed to succeed; be prepared to retry more than once. This is how a server forces proof freshness and makes pre-computed proofs useless. If the server you are integrating with does not issue nonces, enforcing jti uniqueness and an iat window is a sensible policy anyway; treat that as a recommendation rather than a normative rule.

Migrating refresh tokens you already issued

Here is the part the announcement does not spell out, and where I have to be explicit about the limit of the evidence: as far as the published announcement goes, Google's post does not describe a migration procedure for existing refresh tokens. That is an absence-of-evidence claim, not a confirmed absence. The following is my own design, not a documented Google procedure.

Existing refresh tokens are not bound to any key. You cannot retroactively bind them. The realistic path is:

  • Accept unbound refresh tokens during a transition window, but mark them as unbound in your own records.
  • On the next refresh, issue a new, DPoP-bound refresh token and tell the client to start sending proofs.
  • After the window, reject unbound tokens and force re-consent.

To make that concrete, store a bound: true/false flag (or the key thumbprint) on the refresh token record at issuance, and query counts by that flag before the cutover. That last step will log some users out. Plan for it rather than discovering it: measure how many active refresh tokens are unbound before flipping the switch, and prefer forcing a re-auth on a schedule over running two code paths forever.

The tradeoffs nobody puts in the diagram

Concern โ€” What DPoP gives you โ€” What it costs ยท Token theft โ€” Stolen token alone is unusable โ€” Key must be protected separately ยท High-QPS APIs โ€” Access tokens stay Bearer โ€” Refresh path gains a signing step ยท Replay โ€” jti + nonce + iat window โ€” Shared state, TTL management ยท Mobile clients โ€” Hardware keystore can hold the key โ€” Keystore APIs differ per platform ยท Browser clients โ€” WebCrypto can sign โ€” Key is extractable in many setups

Key storage is the tradeoff that decides whether this helps you. On a server, a file with tight permissions or a secrets manager is reasonable. On iOS and Android, the platform keystore can hold a non-exportable key, which is the strongest version of the binding. In a browser, WebCrypto can generate a non-extractable key, but persistence across sessions is awkward, and it is common to end up storing something extractable. If the key is extractable and sits next to the token, an infostealer gets both and the binding buys you nothing.

Clock skew is the second practical annoyance. The iat window has to be wide enough for real-world clock drift and narrow enough to limit replay. The RFC leaves the exact window to implementers, so pick a value you can defend, document it, and make it configurable per environment. Servers with badly synced clocks will produce intermittent failures that look like auth bugs.

Why only refresh tokens? The announcement frames this as avoiding high-QPS API performance overhead: keeping short-lived access tokens as Bearer tokens avoids adding a signature check to every high-QPS API call. That is a reasonable engineering call, and it is the reason the change is easier to adopt than a full proof-of-possession rollout. The residual risk is that a stolen access token still works until it expires, so keep access-token lifetimes short.

Where this fits with agents and MCP

The same announcement notes, separately from DPoP, that teams running multi-agent or Model Context Protocol (MCP) workloads can use Google Cloud Secure Token Service (STS) to handle token delegation across agent chains. That is a separate product surface from DPoP itself, and the evidence does not detail how the two interact. Note that this STS is distinct from the OAuth Security Token Service (also abbreviated STS). If you are building agent systems, the token-delegation problem is real: an agent acting on your behalf needs a credential, and that credential is exactly the kind of thing that leaks. For background on why protocol choice matters here, see MCP vs function calling: when you actually need a protocol and AI agents in production: the boring parts that decide whether they work.

What to do this week

If you maintain an OAuth client, add proof generation behind a flag and start sending proofs on refresh. If you maintain a resource server, implement validation with jti tracking before you accept any bound tokens. If you are on Google's platform, read their docs for the exact nonce and endpoint behavior, because the announcement is a summary, not a spec.

The honest summary: DPoP raises the cost of token theft from "copy a string" to "extract a key from a keystore." That is a real improvement. It is not a substitute for short token lifetimes, scoped tokens, or rotation. Treat it as one layer, and be honest with yourself about where your private key actually lives.

Further reading

Related posts

Sources

Share

About the Author

Comments (0)

Loading comments...