DPoP: Demonstrating Proof of Possession (RFC 9449)

Bind OAuth access tokens to client-held asymmetric key pairs to prevent token theft, replay, and AiTM session hijacking.

Last reviewed September 18, 2026Fresh

Overview

DPoP, RFC 9449, binds an access token to a key pair the client holds. The client signs a short JWT proving possession of the private key, and the resource server accepts the token only alongside a valid proof. A stolen bearer token on its own is then useless.

PasskeyBridge implements this as shield-dpop, with five actions: bind, verify, nonce, introspect and cleanup. Bindings are tenant scoped and hold no plaintext secrets: the access token is stored as a SHA-256 digest, the client IP as a keyed HMAC-SHA-256 digest, and the key is identified only by its JWK thumbprint, RFC 7638.

Who can call what.

ActionCredentialRequirement
bindx-pb-api-keydpop scope, counts against the per-minute quota
verifyx-pb-api-keydpop scope, counts against the per-minute quota
noncex-pb-api-keydpop scope, counts against the per-minute quota
introspectSession JWTTenant member
cleanupSession JWTTenant admin

There is no plan gate on any action. Every request carries tenant_id in the body as a UUID; the header form is not read here.

Where it lives. https://api.passkeybridge.io/v1/shield-dpop. A GET or HEAD answers {"status":"ok","function":"shield-dpop"} unauthenticated. A call to the Supabase functions host answers 403 Direct access denied.

The proof itself is passed in the JSON body as dpop_proof for bind and verify. The DPoP request header is read by the shadow observation path described below, and the public host forwards it.

DPoP binding flow

Step 1. The client builds a proof JWT. The header carries typ of dpop+jwt, an alg from the supported list, and the public JWK. The payload carries htm, htu, iat, jti and ath (the base64url SHA-256 of the access token being bound; required since 2026-09-18, as RFC 9449 section 4.3 requires whenever a proof accompanies an access token), and optionally nonce. The JWK must not contain a private key component.

Step 2. Submit the proof with the token.

POST https://api.passkeybridge.io/v1/shield-dpop
x-pb-api-key: pb_live_...
Content-Type: application/json
{
  "action": "bind",
  "tenant_id": "00000000-0000-0000-0000-000000000000",
  "dpop_proof": "eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...",
  "access_token": "<the bearer token to bind>"
}

Step 3. The platform validates and stores. In order: JWT structure; typ; alg against the allowed list; jwk present and free of private key material; htm, htu, iat, jti and ath present; htu parses as a URL; iat within 60 seconds in the future and 300 seconds in the past; ath matching the base64url SHA-256 of the access token; the signature itself over the first two JWT segments; then the thumbprint. verify runs the same ath check against the token it is presented with, so a proof signed for one token cannot verify a request carrying another.

Two uniqueness checks follow. A jti already recorded for this tenant is a replay. An unconsumed, unexpired binding for the same token and thumbprint already exists is a duplicate.

{
  "status": "bound",
  "binding_id": "0b1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
  "jkt": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
  "expires_at": "2026-09-16T14:35:00.000Z"
}

The binding lives for 300 seconds from creation, the same bound as the maximum proof age. It is single use: the first successful verify consumes it.

A bind writes an audit row with action dpop.bind, the thumbprint as the actor, and htm, htu and alg in its metadata.

DPoP verification (§7)

A resource server calls verify for each DPoP-bound request it receives. It supplies the proof, the token and the method and URI it actually served.

{
  "action": "verify",
  "tenant_id": "00000000-0000-0000-0000-000000000000",
  "dpop_proof": "<dpop jwt>",
  "access_token": "<bearer token>",
  "htm": "POST",
  "htu": "https://api.example.com/resource"
}

All four fields beyond action and tenant_id are required together; a missing one answers 400 Missing required fields: dpop_proof, access_token, htm, htu.

The pipeline: decode the proof, check typ and alg, reject private key material, compare htm exactly, compare htu on scheme, authority and path only so query strings and fragments are ignored, check iat against the same 60 and 300 second bounds, require a jti, verify the signature, compute the thumbprint, then look up an unconsumed unexpired binding for this tenant, token digest and thumbprint.

If the binding carries a nonce and the proof's nonce does not match, the platform writes a fresh nonce onto the binding and returns it with the error, so the client can retry once with the right value.

The binding is then consumed by an update conditional on consumed still being false, so two proofs racing on one binding cannot both pass. The loser gets 401 with code DPOP_REPLAY.

{
  "status": "verified",
  "jkt": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
  "binding_id": "0b1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
  "token_bound": true
}

Because the binding is consumed, a client making a second request must bind again. Treat bind plus verify as a pair per protected request rather than as a session setup.

Server nonce issuance (§8)

The nonce action returns a fresh value in the body and in the DPoP-Nonce response header, with Cache-Control: no-store.

{ "action": "nonce", "tenant_id": "00000000-0000-0000-0000-000000000000" }
{ "dpop_nonce": "9f1c0f3e-9b2a-4e57-8a25-6f31b0c4ad12" }

Understand where the nonce is recorded. The value returned by the nonce action is generated and handed back; nothing stores it. A binding carries a nonce only when the proof presented at bind already contained one, which is then the value verify compares against. The practical consequence today: fetching a nonce and putting it in the next proof makes bind record that value, and verification of that binding then requires it. A nonce fetched and used against a binding that carries none is simply ignored.

The self-correcting path is the one to rely on. When verification fails on a nonce mismatch, the response carries DPOP_NONCE_REQUIRED and a dpop_nonce field, and that value has been written onto the binding, so the retry will match.

Supported algorithms

AlgorithmKey typeCurve or hash
ES256ECP-256
ES384ECP-384
ES512ECP-521
PS256RSA-PSSSHA-256
PS384RSA-PSSSHA-384
PS512RSA-PSSSHA-512

ES256 is the interoperable default. EdDSA is absent from the allowed list because Web Crypto support is inconsistent across the runtimes the platform targets, so a proof presenting alg: "EdDSA" is rejected with 400 Unsupported algorithm: EdDSA. Allowed: ES256, ES384, ES512, PS256, PS384, PS512. Symmetric algorithms such as HS256 are rejected by the same list, as RFC 9449 section 4.2 requires. A JWK containing a d parameter is rejected with 400 DPoP proof must not contain private key material.

Temporal bounds. A proof's iat may be at most 60 seconds ahead of server time and at most 300 seconds behind it. Bindings expire 300 seconds after creation. The clock used is the edge runtime's, and a client with drift should sync against NTP and atomic clock sync before retrying.

Introspection and cleanup

Introspect. Any tenant member with a session JWT can list bindings for a thumbprint.

{ "action": "introspect", "tenant_id": "...", "jkt": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" }
{
  "jkt": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
  "bindings": [
    {
      "id": "0b1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
      "htm": "POST",
      "htu": "https://api.example.com/resource",
      "iat": "2026-09-16T14:30:00.000Z",
      "expires_at": "2026-09-16T14:35:00.000Z",
      "consumed": true,
      "consumed_at": "2026-09-16T14:30:02.000Z",
      "created_at": "2026-09-16T14:30:00.000Z"
    }
  ],
  "active_count": 0
}

The 20 most recent bindings, newest first. active_count counts those that are neither consumed nor expired. The response carries no staleness or posture field; a binding is active, consumed or expired and nothing else.

Cleanup. A tenant admin deletes every binding, consumed or not, whose expires_at passed more than six minutes ago. The six minutes are the proof window plus clock skew: a binding row carries the jti of the proof that consumed it, and that proof is accepted for 300 seconds after its iat (plus 60 seconds of skew), so a row deleted at expiry would let a rebind make an already-used proof acceptable again. Since 2026-09-18 the row outlives every proof it covers.

{ "action": "cleanup", "tenant_id": "..." }
{ "status": "cleaned", "purged": 42 }

There is no separate sweep for stale unconsumed bindings, because the 300 second expiry is the only lifetime a binding has. Nothing removes expired bindings automatically: the shared cleanup_expired_records() routine covers tunnels, proofs, credentials and challenges but has no DPoP step, so rows accumulate until an admin calls this action. A verified proof's jti is also held unique per organization by a database index, so two copies of one proof cannot both verify even against different bindings.

Shadow observation

Alongside enforcement, the platform records whether inbound API requests carry a DPoP header at all. This is measurement only, and it never rejects a request.

What it does. Every request that passes through the shared API-key authentication module, on either the key lane or the tenant-admin JWT lane, queues an observation. The observation reads the dpop request header and inspects it structurally: three JWT segments, typ of dpop+jwt, an alg string, a jwk object, and htm, htu and iat present in the payload. No signature is checked, no binding is looked up. The row goes to shield_dpop_shadow_log with the tenant, the endpoint path, the HTTP method, dpop_present, dpop_valid, a failure_reason from a fixed list (proof_too_large, malformed_jwt, wrong_typ, missing_alg, missing_jwk, missing_htm_htu, missing_iat, parse_error) and a keyed digest of the client IP. Failures are swallowed, so shadow logging can never affect production traffic.

What it does not do. There is no posture or tier on a binding, no shadow_degraded annotation, and no re-attestation interval applied to DPoP bindings. A separate table tracks re-attestation for sessions, agents and API keys on a 15 minute cadence with its own tiers, and shield-dpop neither reads nor writes it.

The header reaches the function through the public host. dpop is on the Cloudflare worker's forward list, so an observation recorded for traffic arriving at api.passkeybridge.io reads the proof the client actually sent, and the adoption numbers measured from this table are real. Enforcement is a separate path: bind and verify take the proof in the JSON body.

Reading the data. shield_dpop_shadow_log is readable by tenant admins under row-level security, and the shield_get_dpop_adoption database function returns totals, per-endpoint counts and adoption and validity percentages for a window in hours. No dashboard tab renders it today. A purge routine that deletes observations older than 30 days exists, and no schedule in the repository calls it. See DPoP shadow quickstart.

Errors

StatusCode or bodyCause
400Missing or invalid tenant_id (UUID)Missing or malformed tenant
400Unknown action. Allowed: bind, verify, nonce, introspect, cleanupUnrecognised action
400Malformed JSON bodyBody did not parse
400Malformed DPoP proof: invalid JWT structureProof was not three segments
400Invalid typ: expected 'dpop+jwt'Wrong typ header
400Unsupported algorithm: [alg]. Allowed: ...alg outside the allowed list
400DPoP proof must not contain private key materialJWK carried d
400Missing 'htm', 'htu', 'iat', 'jti' or 'ath' in proofRequired claim absent
400DPoP proof iat is in the futureMore than 60 seconds ahead
400DPoP proof has expiredMore than 300 seconds old
400DPoP proof 'ath' does not match access tokenath wrong for the token presented
400DPoP htm mismatch or DPoP htu mismatchProof does not match the request being verified
400DPoP nonce required, code DPOP_NONCE_REQUIREDNonce mismatch; the body carries a fresh dpop_nonce to retry with
401DPoP proof signature verification failedSignature did not verify against the JWK
401DPoP binding already consumed, code DPOP_REPLAYBinding was consumed by an earlier verify
403No valid DPoP binding found for this token+key pair, code DPOP_BINDING_NOT_FOUNDNo unconsumed, unexpired binding matched
409Duplicate DPoP proof jti (replay detected)That jti was already bound for this tenant
409Active DPoP binding already exists for this token+key pairBind called twice without verifying
401 or 403x-pb-reason of invalid-key, missing-scope, edge-lockedAPI key lane rejections on bind, verify and nonce
401 or 403Missing authorization header, Invalid auth token, Access denied, Admin access requiredJWT lane rejections on introspect and cleanup
413Payload too largeRequest body above 64 KB
429header x-pb-reason: quota-exceededPer-minute tenant quota exhausted
502Failed to create binding and similarA database call failed

Machine-readable code values appear only on the three cases named above. Everything else is a plain error string.

Verify it worked

  1. Call bind and keep binding_id and jkt from the response.
  2. Call introspect with that jkt using a dashboard session JWT. Your binding is the first entry, consumed is false, and active_count is at least 1.
  3. Call verify with a fresh proof for the same token and key. You get status: "verified" and token_bound: true.
  4. Call introspect again. The same binding now reads consumed: true with a consumed_at timestamp, and active_count has dropped.
  5. For the audit trail, look for dpop.bind and dpop.verify rows on resource type dpop_binding with your binding_id as the resource id.

If step 3 answers DPOP_BINDING_NOT_FOUND, check in this order: the binding expired after 300 seconds, an earlier verify consumed it, or the htu you are verifying differs from the one in the proof on scheme, host or path.

Related from the blog