Bearer tokens fail in a very plain way: if someone can copy the token, they can often replay it.
That does not mean every bearer-token deployment is careless. Short lifetimes, narrow scopes, audience checks, revocation, and careful logging all matter. But bearer tokens still have a blunt property. The API usually validates the token, not the sender. A copied token can look exactly like a legitimate request while it is still valid.
RFC 9449 defines Demonstrating Proof of Possession, or DPoP, as an OAuth mechanism for sender-constraining tokens at the application layer. In the ProAuth v3 preview, DPoP-bound access tokens become part of the advanced client security toolkit alongside other high-assurance OAuth and OIDC controls.
The useful mental model is simple: a stolen DPoP access token is incomplete evidence. The caller also needs a fresh signed proof from the key to which the token was bound.
DPoP adds a second fact
A normal bearer request says:
Authorization: Bearer <access-token>
A DPoP-bound resource request says something stronger:
Authorization: DPoP <access-token>
DPoP: <proof-jwt>
The access token and the proof are separate pieces of data. The access token carries confirmation material, usually cnf.jkt, which is the thumbprint of the client’s public key. The DPoP header carries a signed proof JWT. The resource server validates both and checks that they fit together.
That extra relationship is the point. Copying only the access token no longer gives the attacker everything needed to call the API.
The proof is the signed JWT
DPoP is sometimes misunderstood as if one claim inside the proof does all the work. It does not.
The proof of possession is the signature over the DPoP proof JWT. The JWT header contains the public JWK and an asymmetric signing algorithm. The client signs the proof with the corresponding private key. The resource server validates the signature, hashes the public JWK, and compares the result with the access token’s cnf.jkt.
The proof payload then narrows where that proof can be used:
DPoP proof JWT
|-- header
| |-- typ: dpop+jwt
| |-- alg: ES256, RS256, PS256, or another supported asymmetric algorithm
| `-- jwk: public key
|-- payload
| |-- htm: HTTP method
| |-- htu: target URI without query or fragment
| |-- iat: proof creation time
| |-- jti: unique proof identifier
| |-- ath: hash of the access token for protected resource requests
| `-- nonce: optional server-provided nonce
`-- signature
`-- signed with the client's private key
htm and htu bind the proof to the HTTP request. ath binds it to this access token. jti gives the server a replay handle. iat, short proof windows, and optional nonces limit how much useful lifetime an old proof has.
What the API has to validate
DPoP only helps when the resource server enforces the relationship. Accepting a DPoP-bound token as a normal bearer token loses the control at the place it matters.
flowchart TD
Request["Protected request\nAuthorization: DPoP access_token\nDPoP: proof JWT"]
Token["Validate access token\nissuer, expiry, audience, scope"]
Binding["Read token cnf.jkt\npublic-key thumbprint"]
Proof["Validate proof JWT\ntyp dpop+jwt, alg, public JWK, signature"]
Claims["Check proof claims\nhtm, htu, iat, ath"]
Replay["Check freshness\nunique jti and optional nonce"]
Match["Compare proof JWK thumbprint\nwith access token cnf.jkt"]
Pass{"All validation\nchecks pass?"}
Accept["Accept request"]
Reject["Reject replay or mismatch"]
Request --> Token
Request --> Proof
Token --> Binding
Proof --> Claims
Proof --> Match
Binding --> Match
Claims --> Replay
Token --> Pass
Match --> Pass
Replay --> Pass
Pass -->|yes| Accept
Pass -->|no| Reject
Token -. invalid .-> Reject
Claims -. mismatch .-> Reject
Match -. key mismatch .-> Reject
Replay -. reused or stale proof .-> Reject
The replay check is easy to understate. If an attacker captures both a token and one valid proof JWT, the first proof still should not become a reusable credential. A resource server needs to reject reused jti values within the accepted proof window. Sender constraint without replay handling is unfinished work.
How ProAuth v3 preview issues DPoP-bound tokens
In the ProAuth v3 preview, DPoP is configured per client application through the v2 Management API resource api/management/v2/ClientAppAuthenticationConfiguration.
| Field | Effect |
|---|---|
dpopBoundAccessTokens | Requires valid DPoP proofs for token requests and issues access tokens with token_type DPoP. |
requireDpopNonce | Requires a rotating server-managed nonce and returns it in the DPoP-Nonce response header. |
The tenant option DpopProofTimeWindow controls the accepted iat window for token endpoint proofs. The documented default is 00:05:00.
At the token endpoint, the client sends a DPoP header with a signed proof for the token request. When the proof is valid, ProAuth binds the issued access token to the proof key and returns token_type DPoP. The access token contains confirmation material such as:
{
"cnf": {
"jkt": "base64url-sha256-jwk-thumbprint"
}
}
For authorization-code flows, the preview also supports authorization-code binding with dpop_jkt. The client includes the base64url-encoded SHA-256 JWK thumbprint in the authorization request, or in the pushed authorization request when PAR is used. When dpopBoundAccessTokens is enabled for the client, authorization-code requests must include dpop_jkt, and the proof at the token endpoint must use the same key.
For public clients, refresh tokens issued during a DPoP-bound flow are also bound to the same cnf.jkt. Refresh grants then have to prove possession of the same key.
Nonces make prebuilt proofs less useful
jti and iat help with replay, but a server can also require a nonce when it wants stronger freshness at the token endpoint.
When requireDpopNonce is enabled, a token request without a valid nonce is rejected with use_dpop_nonce. ProAuth returns a DPoP-Nonce response header. The client signs a new proof that includes the nonce and retries the token request. On success, ProAuth rotates the nonce and returns the next value in another DPoP-Nonce header.
The behavior is intentionally interactive. The authorization server is not just accepting a proof the client prepared earlier; it can challenge the client to sign fresh server-provided data.
What DPoP does not solve
DPoP reduces replay value. It does not make the client environment trustworthy.
If malicious JavaScript can run inside a browser client and use the DPoP key while the session is active, DPoP does not turn that browser into a safe place to keep tokens. If a resource server skips proof validation, the access token can drift back toward bearer behavior. If replay state is not shared correctly across production resource-server instances, a reused proof may not be detected consistently.
Those caveats do not make DPoP weak. They describe the boundary of the control. DPoP is strongest when teams combine it with ordinary token discipline: short-lived access tokens, narrow audiences, careful telemetry redaction, fail-closed resource-server validation, and replay signals that are treated as security events.
Where this fits in the v3 preview
DPoP is one of the preview features that moves ProAuth v3 toward sender-constrained OAuth deployments. It is useful where application-layer proof is more practical than TLS client certificates, including clients and integration environments where mTLS would be operationally heavy.
It is also a building block for higher-assurance profiles. ProAuth documents DPoP-bound access tokens as satisfying the sender-constrained-token requirement for the FAPI 2.0 Security Profile. That should still be read as an implementation control, not as a shortcut around threat modeling. High-value APIs need the whole validation path, from token issuance to resource-server enforcement.
The review question for teams is concrete:
If this access token appeared in a log, trace, browser session, or copied command line, would the attacker also have the key and a fresh valid proof?
With bearer tokens, the uncomfortable answer is often “the token may be enough.” With DPoP, the token becomes only part of the evidence.
Further reading: