Theo Zourzouvillys

Blueprint 1stablev1.0.0

DPoP-bound requests across an API surface

By
Theo Zourzouvillys
Published
Requirements
27 · ZBP-1-R1…R27
Tags
securityauthhttptokensdpop

Pull this in

https://zrz.io/zbp/1-dpop-bound-requests/v1.md

Version-pinned. Latest tracks revisions; the pinned URL does not. Requirements are cited individually as ZBP-1-Rk, so an implementation can annotate and a review can check them one at a time.

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.
Provides
dpop-bound-requestssender-constrained-tokens
Rationale
ZFN-6, ZFN-7, ZFN-30, ZFN-45

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 9449RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)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.rfc-editor.org ↗. 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 8705RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access TokensThe 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.rfc-editor.org ↗) 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 7638RFC 7638 — JSON Web Key (JWK) ThumbprintDefines 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.rfc-editor.org ↗) of the proof key’s public half, base64url-encoded. The key’s stable identifier.
  • Confirmation claimcnf 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 8174RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key WordsClarifies 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.rfc-editor.org ↗.

Architecture

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

Resource serverAuthorization serverClient(holds proof key)Resource serverAuthorization serverClient(holds proof key)binds token to the key:cnf.jkt = thumbprint(jwk)verify proof, thenjkt(proof) == token.cnf.jkt1. token request + DPoP proof2. access token (bound)3. Authorization: DPoP <token>DPoP: proof over htm / htu / ath4. 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:

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:

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

The proof’s decoded claims:

{
  "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:

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

A nonce challenge:

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 7638RFC 7638 — JSON Web Key (JWK) ThumbprintDefines 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.rfc-editor.org ↗: 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.

#StepRequirementFailure
1Exactly one DPoP header presentZBP-1-R13invalid_dpop_proof
2Parses as a JWS with three parts; typ is dpop+jwtZBP-1-R15invalid_dpop_proof
3alg is in the server’s permitted set and is asymmetricZBP-1-R14invalid_dpop_proof
4jwk present, public-only, well-formed for algZBP-1-R5, ZBP-1-R15invalid_dpop_proof
5Signature verifies against jwkZBP-1-R15invalid_dpop_proof
6htm matches the request methodZBP-1-R16invalid_dpop_proof
7htu matches the externally visible request URIZBP-1-R16invalid_dpop_proof
8nonce present and current, if the server requires oneZBP-1-R22use_dpop_nonce + fresh DPoP-Nonce
9iat within the acceptance windowZBP-1-R17invalid_dpop_proof
10jti unseen for this key within the window; record itZBP-1-R18invalid_dpop_proof
11Access token itself valid (signature, exp, aud, issuer)invalid_token
12ath matches SHA-256 of the presented tokenZBP-1-R19invalid_dpop_proof
13jkt(jwk) equals the token’s cnf.jktZBP-1-R20invalid_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

ThreatWithout DPoPWith this specification
Token exfiltrated from storage, logs, or a crash reportFull impersonation until expiryUseless: attacker has no proof key (ZBP-1-R20)
Token captured by a malicious or compromised proxyFull impersonationUseless without the key
Proof captured and replayed verbatimRejected: jti single-use (ZBP-1-R18), window bounded (ZBP-1-R17)
Proof replayed against a different endpointRejected: htm/htu bound (ZBP-1-R16)
Proof replayed with a different stolen tokenRejected: ath binds proof to token (ZBP-1-R19)
Attacker generates their own key and a valid proofRejected: thumbprint mismatch (ZBP-1-R20)
Algorithm confusion: proof MAC’d with the public key as secretRejected: symmetric algorithms refused (ZBP-1-R14)
Client device fully compromisedFull impersonationStill compromised. The key is on that device
Request body tampered in flightNot addressedNot 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.

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

Fixed parameters for every vector:

ParameterValue
jkt of the test keybXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4
Access tokeney.demo.access.token.value.for.conformance.vectors
Expected athrnV-hYdXXYDdGEAwKehz-fKzKOzbb-cRTN-GayQmObg
RequestPOST https://api.example.com/v1/transfers
Required nonceeyJ7S_zG.eyJH0-Z.HX4w-7v
Reference time1782000000 (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-6Field Note · currentZFN-6 — Bind tokens to a key: sender-constrained tokens (DPoP)A bearer token grants access to whoever holds it — steal it, replay it. Bind the token to a holder key (DPoP, RFC 9449) so using it requires proving possession of a private key the token names. A stolen token alone becomes useless.Why it's cited here: 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.Open ZFN-6 →; the broader position on proving things about a request rather than trusting it is ZFN-7Field Note · currentZFN-7 — Sign the message, not just the session (HTTP Message Signatures)A bearer token proves nothing about the request it rides on. Sign the message itself (HTTP Message Signatures, RFC 9421) — request, and ideally response — so the recipient can prove who sent this exact message and not a byte changed. Shared keys first; asymmetric better.Why it's cited here: 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.Open ZFN-7 →. Why this is DPoP rather than something bespoke is ZFN-30Field Note · currentZFN-30 — Use the standard; don't reinvent the protocolWhen a standard exists for a common or complex problem, use it — don't reinvent the protocol. Standards encode huge adversarial expertise, especially in auth and crypto; a partial implementation beats rolling your own. You're not that special, and your problem isn't either.Why it's cited here: Why this specifies RFC 9449 rather than a bespoke request-signing scheme, despite the RFC leaving deployment details open.Open ZFN-30 →, and why the RFC itself is worth reading alongside this document is ZFN-45Field Note · currentZFN-45 — Read the standards; better yet, help write themLearn to read standards docs — RFCs, W3C recs — fluently; they're the primary source, not a last resort. Even better, get involved: reading them well makes you a sharper builder, and helping write them is the best protocol education there is.Why it's cited here: 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.Open ZFN-45 →.

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.