Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

Validating proofs at the server

A proof protects nothing until someone checks it. Two servers receive the phone app's proofs, and each has its own decision to make. The authorization server decides whether to bind new tokens to the key in a proof. The photo API decides whether the proof in front of it comes from the key its token was bound to.

Both start from the same list of checks on the proof itself, then add their own.

Checking a proof

For every request that carries a DPoP header, the receiving server confirms that:

  1. There is exactly one DPoP header, and it holds a single, well-formed JWT.
  2. The required claims are present: jti, htm, htu, and iat.
  3. The typ header is dpop+jwt.
  4. The alg header names an asymmetric signature algorithm that this server accepts. none and shared-key algorithms are always refused.
  5. The signature verifies with the public key in the jwk header, and that key contains no private key members.
  6. htm matches the method of this request, and htu matches the address it was sent to, ignoring any query string and fragment.
  7. If this server has given the client a nonce, the proof's nonce claim matches it. Nonces and replay handling covers nonces.
  8. The proof was created recently enough, judged from iat or from the server's own nonce.

The order does not matter, and any failure rejects the request.

Step five may look alarming. Token formats and validation warned never to verify an access token with a key embedded in the token itself, because an attacker can embed their own. A DPoP proof is verified with exactly that kind of key, and it is safe here because the signature is not what makes the proof trustworthy. It only shows that the sender holds the private key matching the public key in the header. Trust comes from the next comparison: whether that public key is the one the authorization server bound to the token. An attacker's proof, signed with the attacker's own key, verifies perfectly and then fails that comparison.

Step six has a practical trap. The htu claim holds the address the client used, such as https://api.photos.example/albums/42/photos. A server behind a load balancer often sees an internal host name or a different scheme. It has to compare against the public address, after normalizing details such as letter case in the host name and default ports. Comparing against the internal address fails every honest proof.

At the token endpoint

When the proof passes, the authorization server computes the thumbprint of its public key and binds the tokens it issues to that thumbprint, as Binding a token to a key showed. When it fails, the token endpoint answers with an ordinary OAuth error response:

HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store

{
  "error": "invalid_dpop_proof"
}

Refreshing works the same way. The phone app is a public client, so its refresh token was bound to its key when it was issued. Every refresh must carry a proof from that same key:

POST /token HTTP/1.1
Host: auth.photos.example
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYi...

grant_type=refresh_token
&refresh_token=demo-app-refresh-token-1
&client_id=photo-printer-app

The proof is shortened here for display. Its htm is POST and its htu is the token endpoint, exactly as in the code exchange. The server checks the proof, then checks that its key's thumbprint equals the one recorded for this refresh token. A refresh token copied out of the phone's storage cannot be redeemed without the key, however long it remains valid.

Confidential clients are treated differently. Their refresh tokens are already tied to the client, because the client must authenticate every time it uses one. The authorization server does not also bind them to a DPoP key. That lets a backend change its DPoP key without losing the grants it holds.

Two further features tighten the binding. A client registered with dpop_bound_access_tokens set to true always uses DPoP, so the server must reject any token request from it without a proof. And the optional dpop_jkt authorization request parameter carries the key's thumbprint at the start of the flow, so the authorization code itself is bound to the key. A code stolen on its way back to the app is then refused unless the token request carries a proof from that key.

At the photo API

When the photo API sees Authorization: DPoP demo-app-access-token-1, the scheme tells it to expect a bound token and a proof. It validates the token exactly as it would any access token, then checks the proof as listed above, then makes the two comparisons that only an API can make:

  • ath in the proof equals the Base64url SHA-256 hash of the token in the Authorization header.
  • The thumbprint of the proof's public key equals the token's cnf.jkt.

For a JWT access token, cnf.jkt is in the token's claims. For an opaque token, it comes from the introspection response. The authorization server does not check the proof during introspection. The API sends only the token, receives the thumbprint, and makes the comparison itself.

A failure gets a challenge in the WWW-Authenticate header, using the same error codes Using access tokens described, with the DPoP scheme and an algs parameter listing the algorithms the API accepts for proofs:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="invalid_token", algs="ES256"

A proof that is itself malformed or wrongly signed can be reported with error="invalid_dpop_proof" instead.

One more case needs a deliberate rule. Suppose someone copies the bound token and sends it as Authorization: Bearer demo-app-access-token-1, with no proof at all. An API that supports both schemes must notice that the token is bound, from its cnf claim or the introspection result, and reject it. Accepting it would turn the binding into an option the attacker can decline.

That rule only exists where an API knows about DPoP. An older API that understands only bearer tokens ignores the unfamiliar cnf claim and accepts the copied token. Binding a token protects it exactly where the binding is checked, so every API that receives DPoP-bound tokens needs these checks before the protection is real.

Try it in the Lab

PUT IT INTO PRACTICE

Check your understanding

Try these questions before moving on. If an answer isn't right, use the feedback and try again.

0 of 2 answered correctly

Enable JavaScript to answer these questions and save progress in this browser.

QUESTION 1 OF 2An attacker sends a stolen DPoP-bound token with a proof signed by their own key, and the signature verifies. Which check stops them?

QUESTION 2 OF 2The photo API supports both Bearer and DPoP. A request arrives with "Authorization: Bearer" and a token whose claims include cnf.jkt, but no proof. What should the API do?

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

Learn identity