You've built an API. You protected it with OAuth 2.0. You're using JWTs. You feel secure.
You're not.
A Bearer token is exactly what the name says — whoever bears it gets in. Steal the token from a log file, a network trace, or a compromised client — and you own the API. No questions asked.
DPoP (RFC 9449) fixes this with sender-constrained access tokens: instead of presenting a token and getting in, the client must prove on every request that it holds the private key the token was issued for. A stolen token without the private key is useless.
How DPoP Actually Works
DPoP ties an access token to a specific key pair. Here's the core idea:
The client generates an asymmetric key pair — typically EC P-256. When requesting a token, it sends the public key embedded into a self-signed JWT to the Authorization Server. The server verifies that signature, embeds the public key in the access token as the cnf (confirmation) claim, and issues the token.
On every API call, the client must now prove it still holds the corresponding private key by attaching a signed proof JWT — the DPoP proof. This proof contains:
-
htm— the HTTP method of the request -
htu— the URL of the request -
jti— a unique identifier to prevent replay -
ath— a hash of the access token it accompanies
The resource server checks that the proof is signed with the key referenced in the token's cnf claim, that it matches the current request, and that it hasn't been seen before. A stolen token is useless without the private key — you can't forge a valid proof. The resource server can also require a nonce in the proof — a short-lived value that further limits the validity window of each proof. More on that below.
The Setup
Four components, running locally via Docker Compose:
Keycloak → issues DPoP-bound access tokens
DPoP Client → proves possession on every request
Heimdall → validates token + proof, enforces deny-by-default
traefik/whoami → simulates the upstream service
Heimdall sits in front of your API. The upstream service knows nothing about DPoP — it receives verified requests and gets on with its job.
The Flow
1. Client generates EC key pair (P-256)
2. Client performs the Authorization Code Grant Flow + PKCE
3. Keycloak issues access token bound to client's public key (cnf claim)
4. Client calls API:
Authorization: DPoP <access_token>
DPoP: <signed proof JWT>
5. Heimdall verifies:
- token validity (issuer, expiry, algorithms)
- proof matches token (ath claim)
- proof matches this request (htm, htu claims)
- proof hasn't been seen before (jti replay check)
- nonce is fresh (DPoP-Nonce challenge)
6. All checks pass → request forwarded to upstream
The Heimdall Config
Two files are all it takes. The first defines the security mechanisms — how tokens are validated and how internal tokens are issued:
secret_management:
nonce_keys:
type: jwks
config:
path: /etc/heimdall/secrets.jwks
signing_keys:
type: pem
config:
path: /etc/heimdall/signer.pem
# references a key defined above used to generate dpop nonce
master_key:
source: nonce_keys
selector: dpop-nonce-master-key-1
mechanisms:
authenticators:
- id: deny_all
type: unauthorized
- id: dpop_jwt
type: jwt
config:
jwks_endpoint:
url: http://keycloak.localhost:8080/realms/dpop/protocol/openid-connect/certs
assertions:
issuers:
- http://keycloak.localhost:8080/realms/dpop
allowed_algorithms:
- RS256
- ES256
validity_leeway: 10s
proof_of_possession:
type: dpop
config:
max_age: 1m
nonce_required: true
replay_allowed: false
error_signaling:
enabled: true
include_dpop_algorithms: true
finalizers:
- id: internal_token
type: jwt
config:
signer:
source: signing_keys
ttl: 1m
# deny all requests by default
default_rule:
execute:
- authenticator: deny_all
- finalizer: internal_token
The piece worth focusing on is proof_of_possession:
proof_of_possession:
type: dpop
config:
max_age: 1m
nonce_required: true
replay_allowed: false
This is where DPoP enforcement actually happens. max_age limits how long a proof is valid. nonce_required forces the client to include a fresh server-issued nonce on every request. replay_allowed: false means each proof can only be used exactly once.
The second file is upstream service-specific, maps routes to those mechanisms, and reconfigures them where needed:
# rules/upstream-rules.yaml
rules:
# allow requests to /api and /api/* carrying a dpop bound access token
# and forward them to upstream:8081
- id: upstream:api
match:
routes:
- path: /api
- path: /api/**
forward_to:
host: upstream:8081
execute:
- authenticator: dpop_jwt
- finalizer: internal_token
config:
claims: |
{
"url": {{ quote .Request.URL.String }},
"service": "upstream"
}
That's it. The upstream receives verified requests — no auth code, no DPoP logic, nothing to maintain.
The Nonce Challenge
The first request to a protected endpoint is deliberately rejected:
HTTP 401
WWW-Authenticate: DPoP error="use_dpop_nonce"
DPoP-Nonce: <fresh nonce>
The client includes the nonce in its next proof. This collapses the replay window from "token lifetime" to one minute — and you can reconfigure it. Heimdall generates and validates nonces using a symmetric key from secret_management. The entire nonce lifecycle stays inside the proxy.
What Heimdall Catches
| Attack | How it's stopped |
|---|---|
| Stolen bearer token |
cnf claim mismatch — not bound to attacker's key |
| Replayed DPoP proof |
jti seen before, replay_allowed: false
|
| Proof for wrong endpoint |
htu mismatch |
| Proof for wrong method |
htm mismatch |
| Stale proof |
max_age: 1m exceeded |
| Wrong token in proof |
ath mismatch |
Beyond DPoP: What You Get For Free
IDP Abstraction
In production, identity providers change — companies consolidate systems, migrate providers, run multiple authorization servers. If your upstream services validate tokens directly against Keycloak's JWKS endpoint, every migration touches every service.
There's a better way: Heimdall becomes the only issuer your upstream trusts.
After validating the DPoP-bound token — nonce check, replay protection, proof-of-possession — Heimdall issues a fresh internal JWT with normalized claims your upstream service expects:
finalizer: internal_token
config:
claims: |
{
"url": {{ quote .Request.URL.String }},
"service": "upstream"
}
The upstream validates against Heimdall's JWKS endpoint — nothing else. Whether the original token was DPoP-bound, which IdP issued it, or what grant flow the client used: irrelevant. Swap Keycloak for Okta or Azure AD — the upstream config doesn't change.
Defense in Depth
Perimeter validation is necessary. It's not sufficient.
A compromised internal service can make direct calls to your API and reach any upstream that trusts arbitrary JWTs. The internal token closes this gap: your upstream only trusts tokens that come from Heimdall and contain the expected claims.
An attacker inside your network can't forge a Heimdall-issued token without the signing key. The signature fails. The request is rejected.
This is the difference between authentication at the perimeter and authentication at every hop.
DPoP and AI Agents
Bearer tokens and AI agents are a bad combination. An agent operating across service boundaries — calling MCP tools, delegating to sub-agents, routing through gateways — may leak tokens through logs, traces, and intermediaries constantly.
DPoP binds the token to the agent's key pair. An intercepted token is useless without the agent's private key. This is why DPoP is being discussed in MCP SEP-1932 as a proof-of-possession mechanism for AI agent authentication — and why Heimdall already implements it.
Try It
Full working example with Docker Compose, Keycloak setup, and DPoP client:
→ dadrus.github.io/heimdall/dev/guides/authn/dpop_bound_access_tokens
The guide targets the dev image — built from the main branch and available on GHCR and Docker Hub — as DPoP support hasn't landed in a stable release yet.
Top comments (0)