Safeguard
Application Security•

ASVS 5.0 V10.4.14: Issue Only Sender-Constrained Access Tokens

At Level 3, bearer tokens are not enough. Bind every access token to a key with DPoP or mutual TLS, and make resource servers check the binding.

Safeguard Security Team
6 min read

ASVS 5.0 V10.4.14 (Level 3) requires that the authorization server issues only sender-constrained access tokens, also called proof of possession tokens. There are two accepted mechanisms: certificate-bound tokens using mutual TLS (RFC 8705), and DPoP-bound tokens (RFC 9449).

A plain bearer token works for whoever holds it. A sender-constrained token works only for the holder of a particular private key, so a copy stolen from a log, a proxy or a compromised service is not enough.

Why it matters

Bearer tokens leak in many mundane ways: debug logs, error reports, browser extensions, a misconfigured gateway that records headers. At the highest assurance level, you assume some of those leaks will happen and make the leaked token worthless on its own.

The constraint has to be enforced at both ends. The authorization server binds the token to a key when issuing it. The resource server verifies, on every call, that the caller proves possession of that key. If the resource server skips the check, the binding does nothing.

How to implement it

Here is the DPoP flow in outline.

  1. The client generates a key pair and keeps the private key non-extractable if it can.
  2. For each token request it sends a DPoP header holding a signed JWT with the HTTP method, URL, a unique ID and a timestamp.
  3. The server issues an access token whose cnf.jkt claim holds the thumbprint of the client's public key, and returns token_type: DPoP.
  4. On each API call the client sends an Authorization header with the scheme DPoP and the token, plus a fresh DPoP proof.

The resource server checks:

def verify_dpop(request, token_claims, replay_cache):
    proof = decode_and_verify_jwt(request.headers["DPoP"], header_jwk=True)
    if proof.header["typ"] != "dpop+jwt":
        raise Unauthorized()
    if proof.claims["htm"] != request.method:
        raise Unauthorized()
    if proof.claims["htu"] != request.url_without_query:
        raise Unauthorized()
    if abs(now() - proof.claims["iat"]) > 60:
        raise Unauthorized()
    if not replay_cache.add_if_new(proof.claims["jti"], ttl=120):
        raise Unauthorized()
    if jwk_thumbprint(proof.header["jwk"]) != token_claims["cnf"]["jkt"]:
        raise Unauthorized()
    if proof.claims["ath"] != b64url(sha256(access_token)):
        raise Unauthorized()

For mutual TLS, the token carries cnf with x5t#S256, the thumbprint of the client certificate, and the resource server compares it with the certificate presented in the TLS handshake. If a proxy terminates TLS, make sure the certificate reaches the resource server through a header it trusts and that outside callers cannot set.

Server products increasingly support both mechanisms. Check yours, and turn on the "require" setting rather than the "allow" one.

The api-security-scan demo below shows how Safeguard inspects an API for common weaknesses, a useful complement to your own token tests:

Click through it at your own pace, or open it full screen.

Where to bind and where to check

Terminate the proof check in one shared library or gateway plugin, so every API gets the same behavior. Teams that copy the verification into each service tend to miss a claim in one of them.

Cache replay identifiers for slightly longer than your allowed clock skew. A two minute cache with a 60 second window is a fair starting point. Behind several instances, use a shared cache so a proof replayed against another node is still caught.

For public browser clients, prefer a server issued nonce in the DPoP proof when your provider supports it. It stops a stolen proof from being precomputed and used later.

How to test it

  1. Obtain a token and call the API with the right key and proof. Expect success.
  2. Take the same access token and call the API with no proof header. Expect 401.
  3. Call with a proof signed by a different key. Expect 401.
  4. Reuse a valid proof twice. The second call should fail.
  5. Change the HTTP method or URL after signing. Expect 401.
  6. Request a token with no DPoP header or client certificate. Expect the server to refuse, not to issue a bearer token.

Automating the check

A short test table keeps the failure cases honest:

CASES = [
    ("valid proof",           {},                         200),
    ("no proof header",       {"drop": "DPoP"},          401),
    ("wrong key",             {"sign_with": "other"},    401),
    ("wrong method",          {"htm": "DELETE"},         401),
    ("wrong url",             {"htu": "https://x/y"},    401),
    ("replayed proof",        {"reuse_last": True},      401),
]

Drive each case against a protected endpoint and assert the status code. Run the table on every deploy of the resource server.

Rollout advice

Moving a live system from bearer to sender-constrained tokens is a migration, not a switch. Start by letting the provider issue constrained tokens to new clients only. Add the proof check to resource servers in log-only mode and look at the failures for a week or two. Fix the clients that omit the header or sign the wrong URL, which behind a proxy often means the public URL differs from the one the service sees. Then enforce, and finally remove the bearer fallback.

Pay attention to proxies. DPoP proofs include the target URL and method, so a gateway that rewrites paths will cause valid proofs to fail. Compute the expected URL from the original request, using forwarded headers you trust.

Why not just shorten token lifetimes

Short lifetimes help, but they only shrink the window. A stolen five minute token still works for five minutes, which is plenty for an automated attacker. Sender constraint changes the question from "how long is the token valid" to "who can use it", and the answer becomes only the holder of the key.

Common mistakes

  • Issuing constrained tokens but leaving a fallback to bearer.
  • Resource servers that accept the token type but never verify the proof.
  • No replay cache for proof identifiers.
  • Allowing extractable keys in browsers, so XSS steals the key as well.

Quick check

What claim links a DPoP-bound access token to a key?

The cnf claim, with a jkt member holding the thumbprint of the client's public key.

Why must the resource server also check?

Binding is only meaningful if the party that accepts the token verifies the proof on each request.

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.