Safeguard
Application Security•

ASVS 5.0 V10.3.5: Sender-Constrained Access Tokens (DPoP, mTLS)

ASVS V10.3.5 (Level 3): make stolen access tokens useless by requiring sender-constrained tokens with DPoP or mutual TLS. How it works and how to test it.

Safeguard Security Team
4 min read

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

  1. Obtain a constrained token. Call the API with only Authorization and no proof. Expect 401.
  2. Replay a captured valid request, including its proof, a second time. Expect rejection on jti.
  3. Replay the proof against a different method or URL. Expect rejection.
  4. Sign a proof with a different key but present the original token. Expect rejection on the jkt comparison.
  5. 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.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.