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:
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:
- Verifies the DPoP proof (see below).
- Computes the JWK Thumbprint (
jkt) of the public key in the proof. - Embeds a
cnf.jktclaim 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:
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:
Header¶
| 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:
- Decodes the DPoP proof and extracts the
jwkfrom the header. - Verifies the signature using the embedded public key.
- Checks
typisdpop+jwt. - Checks
htmmatches the request method. - Checks
htumatches the request URL. - Checks
iatis recent (within a server-configured tolerance window). - Checks
ath— the SHA-256 hash of the access token matches (on resource requests). - Computes the JWK Thumbprint of the proof's
jwkand checks it matches thecnf.jktclaim in the access token. - Checks
jtireplay cache — rejects if thejtihas 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. |