Dieser Blogpost ist auch auf Deutsch verfügbar

  • A Bearer token is a liability: steal it from a log, a trace, or a compromised client, and you own the API.
  • DPoP (RFC 9449) binds the access token to the client’s key pair, so a stolen token is useless without the private key.
  • The hard part lives on the Resource Server (verifying the proof, blocking replays, denying by default), and a proxy like Heimdall handles it out of the box.
  • You get more than DPoP for free: IdP abstraction and defense in depth, plus a pattern that matters for AI agents (MCP SEP-1932).

At the last INNOQ Technology Day I gave a talk on OAuth 2.1. Near the end, someone asked: “Proof of Possession sounds really interesting. How does it actually work?”

My answer was a little too brief. Now that the recording has been online on YouTube for a few weeks, it’s time to give that question the answer it deserves, with a concrete example you can run yourself.

As was hopefully obvious from my answer (timeline 47:13 in the video), DPoP support in the Identity Provider and the Client is only half the story. The Resource Server must verify the proof on every incoming request, detect replay attacks, and deny everything that isn’t explicitly allowed by default. That’s not something you want to implement in every single service. This is exactly where a proxy fits in. And as far as I know, Heimdall, the open-source proxy I maintain, is the first to support this out of the box.

But first, let’s look at the problem it solves.

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 (the resource server)

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 + self-signed JWT
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, and 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, and 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.