SSOGEN / Learn / DPoP OAuth: How Proof-of-Possession Tokens Work

Learn

DPoP OAuth: How Proof-of-Possession Tokens Work

DPoP OAuth ties an access token to a key the client holds and signs a proof on every request, so a stolen token alone cannot be replayed.

Updated

DPoP OAuth flow: a client signs each request with its private key so a stolen access token cannot be replayed
Same token, but only the key holder can use it.

DPoP OAuth binds an access token to a key pair that the client holds, so a stolen token is useless without the private key. The mechanism has three parts:

  • The client generates a key pair and, on every request, signs a short JWT (the proof) with the private key.
  • The authorization server binds the access token to the thumbprint of the public key.
  • The resource server accepts the token only when the proof’s key matches that binding.

RFC 9449 calls this sender-constraining. It detects replay of access and refresh tokens at the application layer and does not depend on the TLS connection.

What DPoP prevents, and what it does not

A bearer token works for whoever holds it. If it leaks through a log, a proxy, a crash dump or a compromised tool, the holder can call the API as the client until the token expires.

A DPoP-bound token carries the thumbprint of the client’s public key. Whoever finds it has the token but not the private key, and cannot produce a valid proof.

A compromised client is outside what DPoP protects. Section 11 of RFC 9449 states that security is no longer guaranteed when an adversary can run code in the client’s execution context.

A non-extractable private key limits the damage. Use a hardware module or an operating system key store.

Authorization stays with scopes and policy. DPoP does not decide what the client may do.

What a DPoP proof JWT contains

The client sends the proof in an HTTP header named DPoP. The proof is a signed JWT with a fixed structure, defined in section 4.2.

Part Field What it carries
Header typ Must be dpop+jwt
Header alg An asymmetric signature algorithm, never none and never a symmetric one
Header jwk The client’s public key. It must not contain a private key
Claims jti A unique id for this proof, so a repeat can be spotted
Claims htm The HTTP method of the request, such as POST
Claims htu The request URI without query and fragment
Claims iat When the proof was created
Claims ath When presenting a token: the base64url SHA-256 hash of the access token
Claims nonce The value the server asked for, if it asked for one

An annotated example (fictional values)

Decoded, a proof that an agent sends to an Acme Corp API looks like this. The comments are annotations and are not valid JSON.

// Header
{
"typ": "dpop+jwt", // marks this JWT as a DPoP proof
"alg": "ES256", // asymmetric, so the server can verify without a shared secret
"jwk": { // the public half only
"kty": "EC",
"crv": "P-256",
"x": "<base64url x>",
"y": "<base64url y>"
}
}
// Claims
{
"jti": "e1j3V_bKic8-LAEB", // never reused
"htm": "GET", // must equal the request method
"htu": "https://api.acme.example/invoices", // must equal the request URI, no query
"iat": 1791297480, // must be recent
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo", // hash of the access token in use
"nonce": "eyJ7S_zG.eyJH0-Z.HX4w-7v" // present only after the server asked for one
}

The request then carries two headers: Authorization: DPoP <access token> and DPoP: <the signed proof>. Note the authorization scheme: a DPoP-bound token is sent as DPoP, not Bearer.

The flow, step by step

Step Who What happens
1 Client Generates a key pair and keeps the private key
2 Client to authorization server Sends the token request with a DPoP proof (no ath yet, since there is no token)
3 Authorization server May reply with the error use_dpop_nonce and a DPoP-Nonce header; the client repeats the request with that nonce in the proof
4 Authorization server Issues a token with token_type set to DPoP, bound to the key’s thumbprint (section 5)
5 Client to resource server Sends Authorization: DPoP <token> plus a fresh proof that includes ath
6 Resource server Validates the proof and checks that its key matches the token’s binding, then serves or refuses

The binding is stored with the token:

  • In a JWT access token, it is the cnf claim with a jkt member: the base64url SHA-256 thumbprint of the public key.
  • For an opaque token, the introspection response carries the same cnf structure (section 6).

What a resource server must check

Section 4.3 defines the checks. Run them in this order on every request:

  1. There is exactly one DPoP header and it parses as a JWT.
  2. typ is dpop+jwt, alg is asymmetric and the signature verifies with the key in jwk.
  3. jwk holds no private key.
  4. htm and htu match the request actually received. Compare against the URI the client called, not an internal address behind a load balancer.
  5. iat falls inside your acceptance window, and jti has not been seen in that window.
  6. ath equals the hash of the access token in the Authorization header.
  7. The thumbprint of jwk equals the jkt bound into the token.
  8. If you issued a nonce, the proof contains it.

When a check fails, respond with 401 and a WWW-Authenticate: DPoP challenge. RFC 9449 defines two error codes for this response: invalid_dpop_proof and use_dpop_nonce (section 7).

Why servers hand out nonces

Without a nonce, a compromised client could pre-sign proofs with future iat values and pass them to an attacker for later use. A server-provided nonce prevents this, because the attacker cannot predict its value.

Section 8 defines the DPoP-Nonce response header. Once a server has asked for a nonce, it must not accept proofs without one.

Common mistakes

  • Comparing htu to the wrong URL. Behind a reverse proxy, the application sees an internal address. Validate against the public URL the client called.
  • Skipping the ath check. Without it, a proof created for one token can accompany another.
  • Omitting the binding check. A valid signature proves only that the sender holds some key. The jkt comparison proves it is the key the token was issued to.
  • Accepting Bearer for a bound token. If the resource server also accepts a DPoP token under the Bearer scheme, the protection is lost.
  • Reusing proofs. Each request needs a fresh jti and iat.
  • Storing the private key where scripts can read it. The attacker then steals the key instead of the token.

DPoP for AI agents

An agent that calls tools continuously is a good fit for short-lived, key-bound tokens: a token that leaks into a transcript or a log is useless without the agent’s key.

What SSOGEN does with DPoP

SSOGEN requires DPoP from AI agents:

  • An agent presents Authorization: DPoP <token> and a fresh proof on every request. A Bearer token is refused.
  • Agent tokens live about four minutes, never more than 270 seconds.
  • SSOGEN can answer a token request with use_dpop_nonce and a DPoP-Nonce header; the agent repeats the request with that nonce.
  • A client that acts for a person must first push its authorization request, with the thumbprint of its DPoP key as dpop_jkt.

The SSOGEN Gateway validates the token and the key proof on each call to an MCP server and does not forward the token to the server.

Read the AI agent identity overview, the MCP Gateway page, and the setup guide with the exact requests.

Questions people ask

What are the limitations of DPoP?

  • It does not protect a client whose own runtime is compromised.
  • It adds a signed proof to every request.
  • Servers must keep a short-lived record of jti values and handle nonces.
  • It only binds tokens to a key; it says nothing about who should have a scope.

Is DPoP the same as a JWT access token?

No. A DPoP proof is a separate JWT that the client signs for every request. The access token is issued by the authorization server and may be a JWT or an opaque string.

Does DPoP replace OAuth scopes?

No. Scopes still limit what the token allows. DPoP limits who can use the token.

Can a browser app use DPoP?

Yes, RFC 9449 allows it. Any script in the page can use the key unless it is non-extractable, so use a non-extractable key wherever the client environment supports one.

Where to go next

Read how OAuth 2.0 is built, how an agent acts for a person with token exchange, and what non-human identity means for the agents that hold these keys. The authoritative text is RFC 9449.

Sources, all read 2026-10-09: RFC 9449.