DPoP
An access token is a bearer token: Whoever holds it can use it. With DPoP (RFC 9449), a caller proves that it holds a private key when it requests a token, and Defakto binds the issued access token to that key. A copy of the token is then useless without the key. MCP adopts DPoP in SEP-1932.
DPoP is optional. A request without a DPoP proof receives an ordinary bearer token. Nothing needs to be registered with Defakto: The caller generates its own key pair, and the proof carries the public key.
This page describes Defakto's values for DPoP. See RFC 9449 for the proof format and how resource servers check proofs.
Where DPoP is supported
| Flow | DPoP |
|---|---|
| Workload access token | Supported |
| User sign-in | Supported |
| Token exchange: on-behalf-of tokens, Transaction Tokens, and external service tokens | Not supported. A request with a DPoP header is refused with invalid_dpop_proof. |
The OAuth server advertises DPoP support for the whole issuer, so a client that sends a proof expects a bound token. Refusing the request, rather than issuing a token that isn't bound, makes sure the client never believes it holds a bound token when it doesn't.
Requesting a bound token
Send a DPoP header with a proof on the token request. For example, a proof for a workload access token request:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
{
"jti": "e1j3V_bKic8-LAEB",
"htm": "POST",
"htu": "https://td-server.defakto.example.com/oauth/token",
"iat": 1792598475
}
The response's token_type is DPoP rather than Bearer, and the access token has a cnf claim with the thumbprint of the proof's key:
{
"access_token": "<access token>",
"token_type": "DPoP",
"expires_in": 300,
"scope": "tickets.read"
}
Defakto's values
| Proof field | Defakto accepts |
|---|---|
alg | ES256 or RS256. RSA keys must be 2048 to 4096 bits. |
htu | The token_endpoint from the OAuth issuer's authorization server metadata, compared as RFC 9449 describes |
iat | Up to 1 minute old, and no more than 5 seconds in the future |
jti | At most 255 characters |
DPoP header | Exactly one per request |
The authorization server metadata lists the accepted algorithms in dpop_signing_alg_values_supported.
A mismatched htu is the most common cause of invalid_dpop_proof. Use the token_endpoint from the metadata, which is the address of your Trust Domain Servers, not the OAuth issuer and not the resource server.
Defakto doesn't use DPoP nonces. It never sends a DPoP-Nonce challenge.
Presenting a bound token
The caller presents a bound token to the resource server as Authorization: DPoP <token>, with a new proof for that request, as RFC 9449 section 7 describes. The resource server checks the proof, and that its key matches the token's cnf claim. Defakto's role ends when it issues the token.
Before you start requesting bound tokens, check that the resource server supports DPoP. A resource server that supports DPoP rejects a bound token presented without a proof. A resource server that doesn't support DPoP ignores cnf, and accepts the token as an ordinary bearer token.
Testing
defakto-dbg oauth-token --dpop generates a key, signs a proof, and requests a bound token:
defakto-dbg oauth-token \
--token-endpoint https://td-server.defakto.example.com/oauth/token \
--resource https://helpdesk.example.com/mcp \
--dpop
The key is generated for each run and then discarded, so the token can't be presented to a resource server. The command tests issuance only.
Errors
The OAuth server refuses a request with invalid_dpop_proof when:
- The proof's signature doesn't verify, or its
typisn'tdpop+jwt algisn't accepted, or thejwkcontains a private keyhtmorhtudoesn't match the requestiatis outside the accepted window, or a required claim is missing- The request has more than one
DPoPheader - The request is a token exchange request