Skip to content

DPoP Token Binding (RFC 9449)

Demonstrating Proof-of-Possession (DPoP) is an opt-in security layer that binds an access token to the client's key pair. If a token is stolen, it cannot be used from a different machine because the attacker does not hold the private key needed to produce valid DPoP proofs.

DPoP is configured per ApiClient via the dpop_required flag. When enabled, every API request must include a DPoP header alongside the Authorization: Bearer header.

Configuration

DPoP is controlled at two levels: a system-wide default and a per-ApiClient override.

Setting Type Default Description
M2M_DPOP_DEFAULT_REQUIRED bool false Require DPoP proofs for all M2M clients unless the client explicitly overrides it

Enable the global default in your settings or environment:

M2M_DPOP_DEFAULT_REQUIRED=true

Individual ApiClient records carry a dpop_required field that takes precedence over the system default. Use it to enable DPoP for specific clients without enforcing it globally, or to exempt a client from a global enforcement.

Enabling DPoP for a specific client

Create or update an ApiClient with dpop_required: true via the admin API:

# Create an API client with DPoP required
curl -X POST https://api.example.com/admin/api-clients \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "payment-processor",
    "scope": "payments:read settlements:create",
    "dpop_required": true
  }'

# Enable DPoP on an existing client (ETag required)
curl -X PATCH https://api.example.com/admin/api-clients/{client_id} \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  -H "If-Match: \"<etag>\"" \
  -H "Content-Type: application/json" \
  -d '{"dpop_required": true}'

Precedence rules

M2M_DPOP_DEFAULT_REQUIRED Client dpop_required field Result
false not set DPoP optional
false true DPoP required for this client
false false DPoP optional for this client
true not set DPoP required (global default)
true false DPoP optional (client-level exemption)
true true DPoP required

Full Flow

Client                               Craft Easy API
  │                                       │
  │  POST /auth/token                     │
  │  DPoP: <proof_for_token_request>      │
  │  client_assertion: <jwt>              │
  ├──────────────────────────────────────▶│
  │                                       │  Verify DPoP proof signature
  │                                       │  Compute jkt = JWK Thumbprint
  │                                       │  Embed cnf.jkt in access token
  │  200 { access_token, token_type: "DPoP" }
  │◀──────────────────────────────────────┤
  │                                       │
  │  GET /resource                        │
  │  Authorization: DPoP <access_token>   │
  │  DPoP: <proof_for_resource_request>   │
  ├──────────────────────────────────────▶│
  │                                       │  Verify signature & typ=dpop+jwt
  │                                       │  Check htm, htu match request
  │                                       │  Verify ath = SHA256(access_token)
  │                                       │  Match proof jwk → cnf.jkt in token
  │                                       │  Check jti replay cache
  │  200 { ... }                          │
  │◀──────────────────────────────────────┤

When to use DPoP

DPoP adds an extra signing step to every request. Enable it when:

  • The client runs in an environment where tokens could be exfiltrated (shared infrastructure, containers with broad access).
  • The data accessed is highly sensitive (financial, PII, compliance-critical).
  • Regulatory requirements demand sender-constrained tokens.

Skip it when the client runs in a trusted environment with strong network isolation and the operational overhead of per-request signing is not justified.

How it works

Token issuance with DPoP

When an ApiClient has dpop_required: true, the token endpoint requires a DPoP header on the token request itself:

POST /auth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs...

The server:

  1. Verifies the DPoP proof (see below).
  2. Computes the JWK Thumbprint (jkt) of the public key in the proof.
  3. Embeds a cnf.jkt claim in the issued access token, binding it to that key.

The resulting access token contains:

{
  "sub": "batch-worker-a1b2c3",
  "scope": "payments:read settlements:create",
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  }
}

Using the token with DPoP

Every subsequent API call must include both headers:

GET /settlements
Authorization: DPoP eyJhbGciOiJFUzUxMiJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

Note: the Authorization scheme changes from Bearer to DPoP when DPoP is active.

The DPoP proof JWT

The client creates a new DPoP proof for every request. The proof is a JWT with:

Field Value Notes
typ dpop+jwt Required — identifies this as a DPoP proof.
alg ES256 or RS256 Must match the DPoP key pair.
jwk Public key (JWK) The full public key in JWK format.

Claims

Claim Value Notes
jti Unique ID UUID or random string. Cached by the server for replay protection.
htm HTTP method The method of the request, e.g. GET, POST.
htu HTTP URL The full URL of the request (without query string), e.g. https://api.example.com/settlements.
iat Current timestamp Unix epoch seconds.
ath Access token hash Base64url-encoded SHA-256 hash of the access token. Required on resource requests (not on the initial token request).

Server verification

On each request, the server:

  1. Decodes the DPoP proof and extracts the jwk from the header.
  2. Verifies the signature using the embedded public key.
  3. Checks typ is dpop+jwt.
  4. Checks htm matches the request method.
  5. Checks htu matches the request URL.
  6. Checks iat is recent (within a server-configured tolerance window).
  7. Checks ath — the SHA-256 hash of the access token matches (on resource requests).
  8. Computes the JWK Thumbprint of the proof's jwk and checks it matches the cnf.jkt claim in the access token.
  9. Checks jti replay cache — rejects if the jti has been seen before.

If any check fails, the request is rejected with 401.

DPoP-Nonce

The DPoP specification includes a server-issued nonce mechanism (DPoP-Nonce header) for additional replay protection. This is reserved but not activated in the current version. When activated in a future release, clients will need to include the server-provided nonce in their DPoP proofs.

Code examples

Python — create a DPoP proof

import hashlib
import base64
import time
import uuid
import json
import jwt  # PyJWT
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization

# Generate a DPoP key pair (do this once per client session)
dpop_private_key = ec.generate_private_key(ec.SECP256R1())
dpop_public_key = dpop_private_key.public_key()

# Convert public key to JWK format
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers
numbers = dpop_public_key.public_numbers()
jwk = {
    "kty": "EC",
    "crv": "P-256",
    "x": base64.urlsafe_b64encode(
        numbers.x.to_bytes(32, "big")
    ).decode().rstrip("="),
    "y": base64.urlsafe_b64encode(
        numbers.y.to_bytes(32, "big")
    ).decode().rstrip("="),
}

def create_dpop_proof(
    method: str,
    url: str,
    access_token: str | None = None,
) -> str:
    """Create a DPoP proof JWT for a request."""
    headers = {
        "typ": "dpop+jwt",
        "alg": "ES256",
        "jwk": jwk,
    }
    claims = {
        "jti": str(uuid.uuid4()),
        "htm": method,
        "htu": url,
        "iat": int(time.time()),
    }
    if access_token:
        # ath = base64url(sha256(access_token))
        token_hash = hashlib.sha256(access_token.encode()).digest()
        claims["ath"] = base64.urlsafe_b64encode(token_hash).decode().rstrip("=")

    # Serialize private key for PyJWT
    private_pem = dpop_private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption(),
    )
    return jwt.encode(claims, private_pem, algorithm="ES256", headers=headers)

Python — full DPoP flow

import httpx

TOKEN_URL = "https://api.example.com/auth/token"

# Step 1 — request token with DPoP proof
dpop_proof = create_dpop_proof("POST", TOKEN_URL)
resp = httpx.post(
    TOKEN_URL,
    headers={"DPoP": dpop_proof},
    data={
        "grant_type": "client_credentials",
        "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        "client_assertion": assertion,  # from private-key-jwt flow
    },
)
access_token = resp.json()["access_token"]

# Step 2 — call an API endpoint with DPoP
resource_url = "https://api.example.com/settlements"
dpop_proof = create_dpop_proof("GET", resource_url, access_token=access_token)
resp = httpx.get(
    resource_url,
    headers={
        "Authorization": f"DPoP {access_token}",
        "DPoP": dpop_proof,
    },
)

cURL — DPoP headers

# Token request (DPoP proof must be generated externally)
curl -X POST https://api.example.com/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "DPoP: ${DPOP_PROOF_TOKEN_REQUEST}" \
  -d "grant_type=client_credentials" \
  -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  -d "client_assertion=${ASSERTION}"

# Resource request
curl https://api.example.com/settlements \
  -H "Authorization: DPoP ${ACCESS_TOKEN}" \
  -H "DPoP: ${DPOP_PROOF_RESOURCE}"

Risk assessment

Client type DPoP recommended? Rationale
Internal service in private VPC No Network isolation is sufficient.
Partner integration over public internet Yes Token theft risk is higher.
Batch job in shared Kubernetes cluster Consider Depends on pod security policies.
CI/CD pipeline No Short-lived, single-use tokens.
Financial data processor Yes Regulatory and data sensitivity.