---
id: 1
title: "DPoP-bound requests across an API surface"
version: "1.0.0"
status: stable
date: 2026-07-27
authors:
  - "Theo Zourzouvillys"
tags: [security, auth, http, tokens, dpop]
summary: "A complete specification for sender-constrained HTTP requests using DPoP (RFC 9449): key handling, proof construction, token binding via cnf.jkt, the resource-server validation algorithm in order, nonces, replay defence, and executable test vectors."
use_when: "You are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key."
not_for:
  - "Service-to-service calls where mutual TLS is already terminated end-to-end — mTLS-bound tokens (RFC 8705) are a better fit."
  - "Protecting the request body's integrity end-to-end; DPoP binds the request line, not the payload."
  - "Authorising *what* a principal may do; this is proof-of-possession, not authorisation."
requires: []
provides:
  - "dpop-bound-requests"
  - "sender-constrained-tokens"
implements_notes: [6, 7, 30, 45]
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-6: "The position this blueprint implements. That note argues why a token should be bound to a holder key at all; everything here is how to do it without leaving the binding decorative."
  ZFN-7: "The broader principle behind proving something about a request rather than trusting it — and the reason this document is careful to say that DPoP covers the request line, not the body."
  ZFN-30: "Why this specifies RFC 9449 rather than a bespoke request-signing scheme, despite the RFC leaving deployment details open."
  ZFN-45: "Why this document is deliberately not a substitute for RFC 9449 — it pins down the deployment choices the RFC leaves to you, and expects the RFC to be read alongside it."
references:
  - id: rfc9449
    title: "RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)"
    url: https://www.rfc-editor.org/rfc/rfc9449
    abstract: "Defines DPoP: sender-constraining OAuth tokens by binding them to a public key the client holds. Each request carries a short JWT proof signed by the matching private key, covering the method, URI, and a hash of the token — so a stolen token is useless without the key."
  - id: rfc7638
    title: "RFC 7638 — JSON Web Key (JWK) Thumbprint"
    url: https://www.rfc-editor.org/rfc/rfc7638
    abstract: "Defines a canonical hash of a JWK: take only the required members for the key type, sort them lexicographically, serialise as JSON with no whitespace, and hash. This is how a key gets a stable, comparable identifier — the `jkt` DPoP binds tokens to."
  - id: rfc8725
    title: "RFC 8725 — JSON Web Token Best Current Practices"
    url: https://www.rfc-editor.org/rfc/rfc8725
    abstract: "The accumulated hard-won guidance on not getting JWTs wrong: always verify the algorithm against an expected set rather than trusting the header, avoid algorithm confusion between symmetric and asymmetric families, validate all claims, and use explicit typing."
  - id: rfc8705
    title: "RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens"
    url: https://www.rfc-editor.org/rfc/rfc8705
    abstract: "The other way to sender-constrain a token: bind it to the client's TLS certificate. Stronger where you control the transport end-to-end, impractical where TLS terminates at a CDN or load balancer — which is why DPoP exists as an application-layer alternative."
  - id: rfc8174
    title: "RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words"
    url: https://www.rfc-editor.org/rfc/rfc8174
    abstract: "Clarifies that MUST, SHOULD, MAY and friends carry their normative meaning only when written in uppercase, so the same words in ordinary prose stay ordinary prose."
---

## TL;DR

This specifies how to make every authenticated HTTP request **sender-constrained**: the caller proves,
per request, that it holds a private key, and the access token names the matching public key. Stealing
the token is no longer enough — an attacker needs the key too, and the key never leaves the client.

It covers the client side (key handling, proof construction), the authorization server side (binding a
token to a key), and the resource server side (the validation algorithm, in order, with the failure
each step produces). It ends with test vectors that actually verify and a conformance checklist.

The mechanism is [DPoP, RFC 9449](ref:rfc9449). This document does not invent anything; it specifies
exactly how to deploy it, including the parts the RFC leaves to the deployment and the parts
implementations habitually get wrong.

## Applicability

**Use this when** you issue or accept access tokens over HTTP and the consequences of a stolen token
are serious: public clients (mobile, SPA, CLI) where tokens sit on hardware you don't control; APIs
where tokens traverse logging, proxies, browser storage, or crash reports; anything where the blast
radius of one leaked token is more than one user's session.

**Do not use this when:**

- **You have end-to-end mutual TLS.** If the client's TLS certificate is visible to the thing checking
  the token, certificate-bound tokens ([RFC 8705](ref:rfc8705)) are simpler and stronger. DPoP exists
  because TLS usually terminates at a load balancer or CDN, destroying that binding. If yours doesn't,
  use the stronger mechanism.
- **You need request payload integrity.** DPoP binds the method and URI, not the body. An attacker who
  can modify a request in flight can change the body without breaking the proof. If you need the body
  covered, you need message signing, which is a different mechanism and a different document.
- **You are trying to solve authorisation.** DPoP answers "is this the party the token was issued to?"
  It says nothing about what that party may do.
- **The client cannot keep a private key.** A proof key stored somewhere the attacker already reaches
  buys nothing. Be honest about this rather than shipping the ceremony without the security.

## Scope and non-goals

In scope: proof-of-possession key lifecycle on the client; DPoP proof construction; access and refresh
token binding at the authorization server; the resource server validation algorithm; nonces; replay
defence; error signalling; observability; rollout to an existing bearer-token API.

Out of scope: how tokens are obtained (any OAuth 2.0 grant works); authorisation policy; payload
signing; key attestation; and the choice of token format. This document assumes JWT access tokens for
concreteness in examples but requires only that the token can carry a confirmation claim and that the
resource server can read it.

## Vocabulary

These terms are used exactly as defined here throughout.

- **Proof key** — the asymmetric key pair held by the client. The private half signs proofs and never
  leaves the client; the public half is embedded in every proof.
- **DPoP proof** — a short-lived JWT, one per HTTP request, signed by the proof key's private half,
  carried in the `DPoP` request header.
- **`jkt`** — the JWK SHA-256 thumbprint ([RFC 7638](ref:rfc7638)) of the proof key's public half,
  base64url-encoded. The key's stable identifier.
- **Confirmation claim** — `cnf` in the access token, containing `jkt`. This is what binds a token to a
  key.
- **Bound token** — an access or refresh token carrying a confirmation claim.
- **Resource server (RS)** — the service accepting the token and validating the proof.
- **Authorization server (AS)** — the service issuing the token and writing the confirmation claim.
- **Nonce** — a server-supplied value the client must echo in a proof, making proof freshness the
  server's decision rather than the client's clock's.
- **Acceptance window** — the period during which the RS will accept a proof based on its `iat`.

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in
[RFC 2119 and RFC 8174](ref:rfc8174).

## Architecture

Three parties, two bindings, one per-request proof:

```mermaid
sequenceDiagram
    participant C as Client<br/>(holds proof key)
    participant AS as Authorization server
    participant RS as Resource server

    C->>AS: 1. token request + DPoP proof
    Note right of AS: binds token to the key:<br/>cnf.jkt = thumbprint(jwk)
    AS-->>C: 2. access token (bound)

    C->>RS: 3. Authorization: DPoP <token><br/>DPoP: proof over htm / htu / ath
    Note right of RS: verify proof, then<br/>jkt(proof) == token.cnf.jkt
    RS-->>C: 4. response — or 401 + DPoP-Nonce
```

The two bindings are what matter, and they are easy to conflate:

1. **Token → key**, written once by the AS as `cnf.jkt`.
2. **Request → key**, proven per request by the DPoP proof.

The security property comes from the RS checking that *both refer to the same key*. A proof alone
proves someone holds *a* key; it proves nothing about *the right* key. Implementations that verify the
proof's signature and stop there have added latency and no security — this is the most common way to
get DPoP wrong, and the reason for the ordering in the validation algorithm below.

## Normative requirements

### Client — key management

- **ZBP-1-R1** The client MUST hold an asymmetric proof key whose private half is never transmitted.
  Proofs MUST be signed with an algorithm from the permitted set: `ES256`, `ES384`, `PS256`, or
  `EdDSA` with Ed25519. The client MUST NOT use `none` or any MAC-based algorithm (`HS*`).
- **ZBP-1-R2** The client MUST use a distinct proof key per installation and principal. A proof key
  MUST NOT be shared between users, devices, or processes; doing so silently re-creates the bearer
  property the mechanism exists to remove.
- **ZBP-1-R3** Where the platform provides hardware-backed or non-exportable key storage (a secure
  enclave, a TPM, a non-extractable `CryptoKey`), the client MUST use it. Where it does not, the client
  MUST store the key in the most restricted storage available and MUST NOT persist it anywhere it is
  serialised alongside the access token.

### Client — proof construction

- **ZBP-1-R4** Every request carrying a bound token MUST include exactly one `DPoP` header field
  containing exactly one proof.
- **ZBP-1-R5** The proof's JOSE header MUST set `typ` to `dpop+jwt`, MUST set `alg` to a permitted
  algorithm, and MUST include `jwk` containing the public proof key. The `jwk` member MUST NOT contain
  any private key parameters.
- **ZBP-1-R6** The proof's claims MUST include `jti` (a value unique per proof, with at least 96 bits
  of entropy), `htm` (the HTTP method, uppercase), `htu` (the request URI with query and fragment
  removed, normalised per RFC 3986: lowercase scheme and host, default port elided), and `iat` (the
  current time as seconds since the epoch).
- **ZBP-1-R7** When the request carries an access token, the `Authorization` header field MUST use the
  `DPoP` scheme rather than `Bearer`, and the proof MUST include `ath` set to the base64url-encoded
  SHA-256 of the ASCII bytes of the access token exactly as sent.
- **ZBP-1-R8** When the server has supplied a nonce for the target, the proof MUST include `nonce` set
  to the most recently supplied value.
- **ZBP-1-R9** A proof MUST be constructed fresh for each request, with a new `jti` and a current
  `iat`. A client MUST NOT cache, reuse, or pre-generate proofs, including across retries of the same
  logical operation.

### Authorization server — binding

- **ZBP-1-R10** When a token request carries a valid DPoP proof, the AS MUST bind the issued access
  token to the proof key by setting the confirmation claim `cnf.jkt` to the RFC 7638 SHA-256 thumbprint
  of the proof key's public half.
- **ZBP-1-R11** For public clients, the AS MUST bind issued refresh tokens to the same key and MUST
  reject a refresh presented with a proof from a different key. An unbound refresh token next to a
  bound access token is a bypass with extra steps.
- **ZBP-1-R12** A token carrying a confirmation claim MUST NOT be accepted under the `Bearer` scheme by
  any party, under any configuration, including during migration. If it is bound, it is bound.

### Resource server — validation

- **ZBP-1-R13** The RS MUST reject a request that carries a bound token with zero or more than one
  `DPoP` header field, before any other proof processing.
- **ZBP-1-R14** The RS MUST reject a proof whose `alg` is absent, is `none`, is MAC-based, or is not in
  the permitted set configured for the deployment. The permitted set MUST be a server-side
  configuration; the RS MUST NOT derive the acceptable algorithm from the proof itself.
- **ZBP-1-R15** The RS MUST verify the proof's signature using the public key in the proof's `jwk`
  header, and MUST reject a proof whose `jwk` contains private key parameters or whose `typ` is not
  `dpop+jwt`.
- **ZBP-1-R16** The RS MUST verify that `htm` equals the request method and that `htu` equals the
  request URI **as the client addressed it** — the externally visible scheme, host, and path — after
  normalisation, with query and fragment removed. Where TLS or routing terminates upstream, the RS MUST
  reconstruct that URI from a trusted source and MUST NOT compare against the internal URI it received.
- **ZBP-1-R17** The RS MUST reject a proof whose `iat` falls outside the acceptance window. The window
  SHOULD be no wider than 60 seconds in total, and MUST be bounded in both directions — a proof from
  the future is as invalid as a stale one.
- **ZBP-1-R18** The RS MUST enforce single use of `jti`, scoped to the proof key, for at least the
  duration of the acceptance window. A replayed `jti` MUST be rejected. This store MUST be shared
  across the instances that can serve the same client, or the check is decorative.
- **ZBP-1-R19** When the request carries an access token, the RS MUST verify that `ath` equals the
  base64url-encoded SHA-256 of the ASCII bytes of the presented token, and MUST reject a proof that
  omits `ath`.
- **ZBP-1-R20** The RS MUST compute the RFC 7638 thumbprint of the proof's `jwk` and MUST verify it
  equals the `cnf.jkt` of the presented access token. A request failing this check MUST be rejected
  even if every other check passed. This is the check that makes DPoP work.
- **ZBP-1-R21** All proof validation MUST complete before the request performs any side-effecting work,
  and the RS MUST fail closed: any error, timeout, or unavailable dependency in validation MUST result
  in rejection, never in acceptance.

### Nonces

- **ZBP-1-R22** The RS and AS MAY require nonces. When a nonce is required and the proof carries none,
  or carries one that is unknown or expired, the server MUST respond `401` with
  `WWW-Authenticate: DPoP error="use_dpop_nonce"` and a `DPoP-Nonce` header field carrying a fresh
  nonce.
- **ZBP-1-R23** A nonce MUST be unpredictable, MUST carry at least 128 bits of entropy, and MUST be
  scoped to the issuing server. A nonce issued by one server MUST NOT be accepted by another unless
  they share a deliberate trust and validation domain.
- **ZBP-1-R24** On receiving `use_dpop_nonce`, the client MUST retry the request exactly once with a
  new proof carrying the supplied nonce, and MUST NOT retry further on a repeated nonce error.

### Transport and operations

- **ZBP-1-R25** All requests in scope MUST use TLS. DPoP constrains who can use a token; it is not a
  substitute for transport confidentiality, and a proof observed in cleartext still reveals the token
  it accompanies.
- **ZBP-1-R26** The RS MUST record the `jkt` of the key used on each authenticated request in its
  request telemetry, and MUST NOT log the access token or the full proof. The `jkt` is what makes
  "this token was used from an unexpected key" a detectable event.
- **ZBP-1-R27** Error responses MUST use `401` with `WWW-Authenticate: DPoP` and an `error` of
  `invalid_dpop_proof` (the proof is bad) or `invalid_token` (the token is bad or the binding does not
  match). The response MUST NOT reveal which specific validation step failed beyond these codes.

## Wire format

A complete bound request:

```http
POST /v1/transfers HTTP/1.1
Host: api.example.com
Authorization: DPoP ey.demo.access.token.value.for.conformance.vectors
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
Content-Type: application/json

{"amount": 100, "to": "acct_123"}
```

The proof's decoded JOSE header:

```json
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "crv": "P-256",
    "kty": "EC",
    "x": "7aR7FGh5rmrytOqv8SqEZLiuAbMmSFNKj4KTNoESv8o",
    "y": "jeHRZVNJeDA2A0UimcloxF-cS8iVxM4LkwdHu078hc4"
  }
}
```

The proof's decoded claims:

```json
{
  "jti": "0b8f2c4e-7a19-4d55-9b3a-2e6c1f8d4a70",
  "htm": "POST",
  "htu": "https://api.example.com/v1/transfers",
  "iat": 1782000000,
  "ath": "rnV-hYdXXYDdGEAwKehz-fKzKOzbb-cRTN-GayQmObg",
  "nonce": "eyJ7S_zG.eyJH0-Z.HX4w-7v"
}
```

The corresponding access token's binding claim:

```json
{
  "iss": "https://auth.example.com",
  "sub": "user_8f21",
  "aud": "https://api.example.com",
  "exp": 1782000600,
  "cnf": { "jkt": "bXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4" }
}
```

A nonce challenge:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"
DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v
```

### Computing the thumbprint

The `jkt` is not a hash of the JWK as you serialised it. It is the hash of the **canonical** form
defined by [RFC 7638](ref:rfc7638): only the required members for the key type, sorted lexicographically
by key, serialised as JSON with no whitespace, hashed with SHA-256, base64url-encoded without padding.
For an EC key the required members are exactly `crv`, `kty`, `x`, `y` — in that order, which is already
lexicographic:

```
{"crv":"P-256","kty":"EC","x":"7aR7FGh5rmrytOqv8SqEZLiuAbMmSFNKj4KTNoESv8o","y":"jeHRZVNJeDA2A0UimcloxF-cS8iVxM4LkwdHu078hc4"}
  → SHA-256 → base64url → bXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4
```

For OKP keys the members are `crv`, `kty`, `x`. Any extra member — `alg`, `kid`, `use` — MUST be
excluded before hashing, or the thumbprint will differ between two systems describing the same key.
This is the most common source of "the binding never matches" in a new deployment.

## Validation algorithm

The resource server MUST perform these steps in this order. The order is not cosmetic: cheap checks
precede expensive ones, and no step may trust a value that a later step validates.

| # | Step | Requirement | Failure |
|---|---|---|---|
| 1 | Exactly one `DPoP` header present | ZBP-1-R13 | `invalid_dpop_proof` |
| 2 | Parses as a JWS with three parts; `typ` is `dpop+jwt` | ZBP-1-R15 | `invalid_dpop_proof` |
| 3 | `alg` is in the server's permitted set and is asymmetric | ZBP-1-R14 | `invalid_dpop_proof` |
| 4 | `jwk` present, public-only, well-formed for `alg` | ZBP-1-R5, ZBP-1-R15 | `invalid_dpop_proof` |
| 5 | Signature verifies against `jwk` | ZBP-1-R15 | `invalid_dpop_proof` |
| 6 | `htm` matches the request method | ZBP-1-R16 | `invalid_dpop_proof` |
| 7 | `htu` matches the externally visible request URI | ZBP-1-R16 | `invalid_dpop_proof` |
| 8 | `nonce` present and current, if the server requires one | ZBP-1-R22 | `use_dpop_nonce` + fresh `DPoP-Nonce` |
| 9 | `iat` within the acceptance window | ZBP-1-R17 | `invalid_dpop_proof` |
| 10 | `jti` unseen for this key within the window; record it | ZBP-1-R18 | `invalid_dpop_proof` |
| 11 | Access token itself valid (signature, `exp`, `aud`, issuer) | — | `invalid_token` |
| 12 | `ath` matches SHA-256 of the presented token | ZBP-1-R19 | `invalid_dpop_proof` |
| 13 | `jkt(jwk)` equals the token's `cnf.jkt` | ZBP-1-R20 | `invalid_token` |

Two ordering notes worth internalising. **Step 5 before steps 6–12**: every claim is attacker-supplied
until the signature is verified, so no claim may influence a decision before then — including the
decision about which key to verify with, which is why the permitted algorithm set is server
configuration (step 3) and not read from the proof. **Step 10 records as it checks**: the replay check
must be an atomic test-and-set, not a read followed by a write, or two concurrent replays both pass.

## Threat model and failure modes

| Threat | Without DPoP | With this specification |
|---|---|---|
| Token exfiltrated from storage, logs, or a crash report | Full impersonation until expiry | Useless: attacker has no proof key (ZBP-1-R20) |
| Token captured by a malicious or compromised proxy | Full impersonation | Useless without the key |
| Proof captured and replayed verbatim | — | Rejected: `jti` single-use (ZBP-1-R18), window bounded (ZBP-1-R17) |
| Proof replayed against a different endpoint | — | Rejected: `htm`/`htu` bound (ZBP-1-R16) |
| Proof replayed with a *different* stolen token | — | Rejected: `ath` binds proof to token (ZBP-1-R19) |
| Attacker generates their own key and a valid proof | — | Rejected: thumbprint mismatch (ZBP-1-R20) |
| Algorithm confusion: proof MAC'd with the public key as secret | — | Rejected: symmetric algorithms refused (ZBP-1-R14) |
| Client device fully compromised | Full impersonation | **Still compromised.** The key is on that device |
| Request body tampered in flight | Not addressed | **Not addressed.** DPoP covers method and URI only |

The last two rows are the honest limits. DPoP moves the attack from "steal a string" to "compromise the
device that holds the key", which is a large and worthwhile increase in cost. It does not make a
compromised client safe, and it does not protect the payload.

The operational failure modes are worth planning for as carefully as the attacks:

- **Clock skew** is the top source of legitimate-traffic rejection (ZBP-1-R17). Mobile clients drift;
  some drift by hours. Nonces (ZBP-1-R22) make freshness server-determined and are the correct fix. A
  wider window is not.
- **The replay store** (ZBP-1-R18) is a shared, hot, write-heavy dependency on the request path. Size
  it for peak request rate times window length, give entries a TTL matching the window, and decide
  deliberately what happens when it is unavailable — ZBP-1-R21 says reject, which means the store's
  availability is now your API's availability. Budget for that or narrow the window.
- **URI reconstruction** (ZBP-1-R16) breaks whenever routing changes shape: a new CDN, a path rewrite,
  an added port. Failures look like a mass client outage with no code change on either side.
- **Key loss** on the client — reinstall, storage eviction, enclave reset — invalidates every bound
  token that client holds. The recovery path is re-authentication, and it must be built before launch,
  not discovered in production.

## Anti-patterns

If you find yourself doing any of these, the specification has been misread.

- **Verifying the proof but not the thumbprint.** The single most common implementation error: steps
  1–12 pass, step 13 is missing, and the system accepts any well-formed proof from any key. It has all
  the cost of DPoP and none of the benefit (ZBP-1-R20).
- **Accepting a bound token as a bearer token "for now".** A migration flag that lets a token with
  `cnf.jkt` through under `Bearer` makes every other requirement decorative (ZBP-1-R12).
- **Reusing one proof key across a fleet, or embedding one in a shipped binary.** The key becomes as
  widely held as the token was. This is a bearer token with extra latency (ZBP-1-R2).
- **Skipping the replay cache because "the window is short".** Sixty seconds is a long time at machine
  speed (ZBP-1-R18).
- **Widening the acceptance window to fix skew complaints.** The window is a security parameter. Use
  nonces (ZBP-1-R22).
- **Binding access tokens but not refresh tokens.** The attacker takes the longer-lived, unbound
  credential and mints bound tokens for their own key (ZBP-1-R11).
- **Putting `kid` or `alg` in the JWK before thumbprinting.** Produces a thumbprint that matches nothing
  and a binding that never validates.
- **Treating a DPoP proof as authentication of the user.** It proves key possession. Who that key
  belongs to is the access token's job.

## Test vectors

All vectors use one test key. **This is a published test key. It MUST NOT be used for anything real.**

```json
{
  "kty": "EC",
  "crv": "P-256",
  "x": "7aR7FGh5rmrytOqv8SqEZLiuAbMmSFNKj4KTNoESv8o",
  "y": "jeHRZVNJeDA2A0UimcloxF-cS8iVxM4LkwdHu078hc4",
  "d": "myaqePLZDl4nm05bFNSLCY9vi-73x0aFPJWnBAdPBGs"
}
```

Fixed parameters for every vector:

| Parameter | Value |
|---|---|
| `jkt` of the test key | `bXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4` |
| Access token | `ey.demo.access.token.value.for.conformance.vectors` |
| Expected `ath` | `rnV-hYdXXYDdGEAwKehz-fKzKOzbb-cRTN-GayQmObg` |
| Request | `POST https://api.example.com/v1/transfers` |
| Required nonce | `eyJ7S_zG.eyJH0-Z.HX4w-7v` |
| Reference time | `1782000000` (2026-06-21T00:00:00Z) |

A harness MUST freeze its clock to the reference time, or vector V1 will fail the window check
(ZBP-1-R17) rather than passing. Every vector below is genuinely signed with the test key, so each one
fails at the step it is meant to test rather than failing earlier at the signature check.

### V1 — valid (expect: accept)

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiIwYjhmMmM0ZS03YTE5LTRkNTUtOWIzYS0yZTZjMWY4ZDRhNzAiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJhdGgiOiJyblYtaFlkWFhZRGRHRUF3S2Voei1mS3pLT3piYi1jUlROLUdheVFtT2JnIiwibm9uY2UiOiJleUo3U196Ry5leUpIMC1aLkhYNHctN3YifQ.FKxAox_KdrSC_uupbQA-vFe9Namd6Cpeq2t4fgmu4CEt0dvKKATal3YrpUvhMDgYE0BAhw58xElpsjg5bpWDOw
```

All thirteen steps pass. A harness that rejects this vector has a bug; a harness that accepts every
vector below has a security hole.

### V2 — method mismatch (expect: reject at step 6, `invalid_dpop_proof`)

Signed correctly, but `htm` is `GET` while the request is `POST`. Tests ZBP-1-R16.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiIyYzVhN2YwMS00ZDhiLTRhMTItOWU3Ny01YjBjM2QxZThmNDQiLCJodG0iOiJHRVQiLCJodHUiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbS92MS90cmFuc2ZlcnMiLCJpYXQiOjE3ODIwMDAwMDAsImF0aCI6InJuVi1oWWRYWFlEZEdFQXdLZWh6LWZLektPemJiLWNSVE4tR2F5UW1PYmciLCJub25jZSI6ImV5SjdTX3pHLmV5SkgwLVouSFg0dy03diJ9.Gd7Z5OMsojqx5cQC3m8qeCLsDojMxK4ycMmsws713NGFybz2iuOCzWA5eeaVUmXe3BJuqAu-4WFowaWr7QlYRQ
```

### V3 — stale proof (expect: reject at step 9, `invalid_dpop_proof`)

`iat` is one hour before the reference time. Tests ZBP-1-R17.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiIzZTkxYjZhMi0wYzQ3LTRmMzgtOGQ1ZS02YTJiOWM0ZDdlMTAiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgxOTk2NDAwLCJhdGgiOiJyblYtaFlkWFhZRGRHRUF3S2Voei1mS3pLT3piYi1jUlROLUdheVFtT2JnIiwibm9uY2UiOiJleUo3U196Ry5leUpIMC1aLkhYNHctN3YifQ.rLXyf2ARfhkQQVFzLRU7rZ6yl3SCl6bX8ayMskufuMfSAI_NxwfkyEX9xy_e8ofZIBkKi-zNLtHJPomKl1UVPA
```

### V4 — algorithm confusion (expect: reject at step 3, `invalid_dpop_proof`)

`alg` is `HS256`, MAC'd using the serialised public JWK as the shared secret — the classic attack on
implementations that take the algorithm from the header. Tests ZBP-1-R14.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkhTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiI0ZjBlMWQyYy0zYjRhLTQ5NTgtODY3Ny0wYTFiMmMzZDRlNWYiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJhdGgiOiJyblYtaFlkWFhZRGRHRUF3S2Voei1mS3pLT3piYi1jUlROLUdheVFtT2JnIiwibm9uY2UiOiJleUo3U196Ry5leUpIMC1aLkhYNHctN3YifQ.Ie4PolpKBCZJ2xmk82nrucah6cxNvywPTNQ29_jdNTI
```

### V5 — missing `ath` (expect: reject at step 12, `invalid_dpop_proof`)

A valid proof with no `ath` claim: what an attacker replays when moving a captured proof onto a
different stolen token. Tests ZBP-1-R19.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiI1YTRkMmI5MC03ZTYzLTRjODEtOWYyYS0xZDhlNWIzYzcwNDkiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJub25jZSI6ImV5SjdTX3pHLmV5SkgwLVouSFg0dy03diJ9.Cfn9T3m6sej-vcw2JSbP6kH7ByavyOxBBZbLpuk2W17TM98z-dpJVwKRahqIIFK6Anvvigh_vfiG-xGGzzGcYg
```

### V6 — missing nonce (expect: reject at step 8, `use_dpop_nonce` + fresh `DPoP-Nonce`)

Valid in every other respect. Only applicable when the server requires nonces. Tests ZBP-1-R22.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiI2YjdjOGQ5MC0xZTJmLTRhM2ItOGM1ZC05ZTBmMWEyYjNjNGQiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJhdGgiOiJyblYtaFlkWFhZRGRHRUF3S2Voei1mS3pLT3piYi1jUlROLUdheVFtT2JnIn0.PY1So7I8FqCtANT2f1s_jtHvD3EtssFePWBaw7UoimHkorjXHU-zHPKmYxiHQJqNHIdLLsaM51zUgwWfL5tZ2A
```

### V7 — wrong key (expect: reject at step 13, `invalid_token`)

**The vector that matters.** A perfectly valid, correctly signed proof from a *different* key
(`jkt` = `B4-46AzkaNwMdynBfeQo9gOAdOpzV4K4kMfnxZH0_wQ`), presented with the access token bound to the
test key. This is exactly what an attacker holding a stolen token produces: they cannot forge a proof
for a key they do not have, so they use their own. Steps 1 through 12 all pass. Only ZBP-1-R20 stops
it — an implementation missing that check accepts this vector, and its DPoP deployment provides no
security whatsoever.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiUzFlVHVoYkJBUGFwZFJTOWFmY2tscmM2TzZpby1DTjVTTXV4NkFVVTJhcyIsInkiOiJxZ0tzUjdBUnVwUEd4b1ZjMTlHREV6cU5fRGNWWmk4a18xOUwxVkhmVFg4In19.eyJqdGkiOiI3YThiOWMwZC0xZTJmLTQzMDQtOTUxNi0yNzM4NDlhYmNkZWYiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJhdGgiOiJyblYtaFlkWFhZRGRHRUF3S2Voei1mS3pLT3piYi1jUlROLUdheVFtT2JnIiwibm9uY2UiOiJleUo3U196Ry5leUpIMC1aLkhYNHctN3YifQ.sFLBekjbO3YX3E_mdJ9qIuZMKRT1r0uVuIyDVTCN5VL3AO9cUsCOT8wOQQqimaCjapMvwvW44x7RDLamLtaClw
```

### V8 — replay (expect: first accept, second reject at step 10)

Submit vector V1 twice. The first MUST be accepted and the second MUST be rejected with
`invalid_dpop_proof`. Tests ZBP-1-R18. This is the only vector that requires state, and it is the one
most often missing from a test suite — because it passes trivially in a single-instance test
environment and fails silently in a multi-instance production one.

## Rollout

Adding this to an API that already accepts bearer tokens, without an outage:

1. **Issue bound tokens.** The AS begins honouring DPoP proofs at the token endpoint and writing
   `cnf.jkt` (ZBP-1-R10). Clients that send no proof still get unbound tokens. Nothing breaks.
2. **Validate but do not enforce.** The RS runs the full validation algorithm on requests that carry a
   proof, records the outcome as telemetry, and rejects nothing. This is where URI reconstruction
   (ZBP-1-R16) and thumbprint canonicalisation errors surface, at zero customer cost. Note that
   ZBP-1-R12 still applies from day one: a token carrying `cnf.jkt` must never be accepted as a bearer
   token, in any phase.
3. **Enforce per audience.** Turn on rejection for one API surface at a time, highest-value first.
   Watch the rejection reasons: a spike in window failures means clock skew and calls for nonces, not a
   wider window.
4. **Require binding.** The AS stops issuing unbound tokens for client types that support DPoP. The
   remaining unbound population is now a measurable, closable list.
5. **Retire the bearer path.** Reject unbound tokens entirely for those client types.

Steps 2 and 3 are where the schedule actually goes; budget for the observation period rather than
treating it as a formality.

## Conformance checklist

An implementation conforms when every box is checked. Boxes map to MUST-level requirements.

**Client**

- [ ] Asymmetric key, permitted algorithm, private half never transmitted (ZBP-1-R1)
- [ ] Distinct key per installation and principal (ZBP-1-R2)
- [ ] Hardware-backed storage used where the platform offers it (ZBP-1-R3)
- [ ] Exactly one `DPoP` header per request (ZBP-1-R4)
- [ ] `typ`, `alg`, and public-only `jwk` in the header (ZBP-1-R5)
- [ ] `jti`, `htm`, `htu`, `iat` present and correctly formed (ZBP-1-R6)
- [ ] `DPoP` authorization scheme and correct `ath` when a token is sent (ZBP-1-R7)
- [ ] `nonce` echoed when supplied (ZBP-1-R8)
- [ ] Fresh proof per request, including retries (ZBP-1-R9)
- [ ] Exactly one retry on `use_dpop_nonce` (ZBP-1-R24)

**Authorization server**

- [ ] `cnf.jkt` written on tokens issued against a proof (ZBP-1-R10)
- [ ] Refresh tokens bound for public clients, and key-checked on refresh (ZBP-1-R11)
- [ ] Nonce issuance is unpredictable and scoped, if nonces are used (ZBP-1-R23)

**Resource server**

- [ ] Exactly-one-header check precedes all other proof handling (ZBP-1-R13)
- [ ] Permitted algorithms come from server config, never from the proof (ZBP-1-R14)
- [ ] Signature verified before any claim is used (ZBP-1-R15)
- [ ] `htm`/`htu` compared against the externally visible request URI (ZBP-1-R16)
- [ ] Acceptance window bounded in both directions, ≤ 60s (ZBP-1-R17)
- [ ] `jti` single-use enforced atomically in a store shared across instances (ZBP-1-R18)
- [ ] `ath` verified, and a missing `ath` rejected (ZBP-1-R19)
- [ ] **Thumbprint of the proof key compared to `cnf.jkt`** (ZBP-1-R20)
- [ ] Validation completes before side effects; failure closes (ZBP-1-R21)
- [ ] Nonce challenge returns `401` with `use_dpop_nonce` and a fresh nonce (ZBP-1-R22)
- [ ] Bound tokens rejected under the `Bearer` scheme everywhere (ZBP-1-R12)
- [ ] TLS required on every route in scope (ZBP-1-R25)
- [ ] `jkt` recorded per request; token and proof never logged (ZBP-1-R26)
- [ ] Error responses use the specified codes and reveal nothing further (ZBP-1-R27)

**Test suite**

- [ ] Vectors V1–V7 produce the stated outcomes with the clock frozen at the reference time
- [ ] V8 (replay) is exercised against a multi-instance deployment, not a single process
- [ ] V7 is asserted explicitly — it is the vector that proves the deployment is not decorative

## Rationale

Why bind tokens to a key at all is argued in [ZFN-6](/zfn/6-sender-constrained-tokens-dpop/); the
broader position on proving things about a request rather than trusting it is
[ZFN-7](/zfn/7-sign-the-message/). Why this is DPoP rather than something bespoke is
[ZFN-30](/zfn/30-use-standards-dont-reinvent/), and why the RFC itself is worth reading alongside this
document is [ZFN-45](/zfn/45-read-the-standards/).

One point deserves repeating because it is where deployments fail. The value of this mechanism is
concentrated almost entirely in one comparison — the proof key's thumbprint against the token's
confirmation claim (ZBP-1-R20). Everything else is scaffolding that makes that comparison meaningful.
An implementation can pass a code review, look thoroughly professional, emit correct-looking proofs on
every request, and provide precisely zero security by omitting one equality check. That is why V7
exists, and why it should be the first test written rather than the last.

## Changelog

- **2026-07-27** (1.0.0): First published. Specifies client key handling and proof construction,
  authorization-server binding via `cnf.jkt`, the thirteen-step resource-server validation algorithm,
  nonce handling, replay defence, error signalling, a staged rollout, and test vectors V1–V8.
