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 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
cnfclaim with ajktmember: the base64url SHA-256 thumbprint of the public key. - For an opaque token, the introspection response carries the same
cnfstructure (section 6).
What a resource server must check
Section 4.3 defines the checks. Run them in this order on every request:
- There is exactly one
DPoPheader and it parses as a JWT. typisdpop+jwt,algis asymmetric and the signature verifies with the key injwk.jwkholds no private key.htmandhtumatch the request actually received. Compare against the URI the client called, not an internal address behind a load balancer.iatfalls inside your acceptance window, andjtihas not been seen in that window.athequals the hash of the access token in theAuthorizationheader.- The thumbprint of
jwkequals thejktbound into the token. - 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
htuto the wrong URL. Behind a reverse proxy, the application sees an internal address. Validate against the public URL the client called. - Skipping the
athcheck. 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
jktcomparison proves it is the key the token was issued to. - Accepting
Bearerfor a bound token. If the resource server also accepts a DPoP token under theBearerscheme, the protection is lost. - Reusing proofs. Each request needs a fresh
jtiandiat. - 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.
- Token exchange. DPoP is the proof mechanism that OAuth token exchange and agent identity build on.
- MCP. It is the mechanism named in MCP authentication.
What SSOGEN does with DPoP
SSOGEN requires DPoP from AI agents:
- An agent presents
Authorization: DPoP <token>and a fresh proof on every request. ABearertoken is refused. - Agent tokens live about four minutes, never more than 270 seconds.
- SSOGEN can answer a token request with
use_dpop_nonceand aDPoP-Nonceheader; 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
jtivalues 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.