ASVS 5.0 V10.3.5 requires the resource server to prevent stolen or replayed access tokens by requiring sender-constrained tokens, using either mutual TLS for OAuth 2 or OAuth 2 Demonstration of Proof of Possession (DPoP). It is a Level 3 requirement. A plain bearer token works for whoever holds it. A sender-constrained token works only for the party that holds a matching private key.
Why it matters
Bearer tokens leak: through logs, proxies, malware, compromised third party code or an overly chatty debug tool. When one leaks, the thief can call your API as the user until it expires. With sender constraint, the stolen string alone is not enough, because every request must also carry a fresh proof signed with a key the thief does not have.
How to implement it
There are two standard mechanisms.
Mutual TLS (RFC 8705). The client authenticates with a TLS certificate. The authorization server binds the access token to the certificate thumbprint in a cnf claim (x5t#S256). The resource server compares that thumbprint with the certificate on the TLS connection. This suits server to server traffic where you control certificates.
DPoP (RFC 9449). The client holds a key pair and sends a signed DPoP header proof with each request. The token carries cnf.jkt, the thumbprint of the client's public key. This suits browser and mobile clients that cannot use client certificates.
A resource server that accepts DPoP must check, per request:
// outline of the checks, using a vetted DPoP library for the details
function verifyDpop(req, accessTokenClaims) {
const proof = parseAndVerifyJwt(req.headers["dpop"]); // signature with the jwk in its own header
assert(proof.header.typ === "dpop+jwt");
assert(proof.payload.htm === req.method); // bound to HTTP method
assert(proof.payload.htu === canonicalUrl(req)); // bound to the URL
assert(withinSeconds(proof.payload.iat, 60)); // fresh
assert(!replayCache.has(proof.payload.jti)); // single use
replayCache.add(proof.payload.jti, 120);
assert(proof.payload.ath === sha256b64url(accessToken)); // bound to this access token
assert(thumbprint(proof.header.jwk) === accessTokenClaims.cnf.jkt);
}
Also require the DPoP authentication scheme on the Authorization header, so a DPoP bound token is never accepted as a plain Bearer. Reject any token that has no cnf binding when your policy demands constraint.
Use a maintained library for proof validation instead of writing it yourself. Details such as URL normalization and replay windows are easy to get wrong.
How to test it
- Obtain a constrained token. Call the API with only
Authorizationand no proof. Expect 401. - Replay a captured valid request, including its proof, a second time. Expect rejection on
jti. - Replay the proof against a different method or URL. Expect rejection.
- Sign a proof with a different key but present the original token. Expect rejection on the
jktcomparison. - For mTLS, present the token over a connection with a different client certificate. Expect rejection.
Common mistakes
- Issuing constrained tokens but still accepting them as plain bearer tokens.
- No replay cache for the proof identifier.
- Not binding the proof to the access token with
ath. - Checking the proof signature but not that its key matches
cnf. - Terminating TLS at a proxy and losing the client certificate without passing it on safely.
Quick check
What makes a DPoP bound token useless to a thief?
Every request needs a fresh proof signed by the client's private key, which the thief does not have.
Where does the resource server get the expected key from?
From the cnf claim of the access token, or from introspection, never from the request alone.