DEV Community

Dimitrij Drus
Dimitrij Drus

Posted on

Stop Using Bearer Tokens Like House Keys: DPoP with Heimdall

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The piece worth focusing on is proof_of_possession:

proof_of_possession:
  type: dpop
  config:
    max_age: 1m
    nonce_required: true
    replay_allowed: false
Enter fullscreen mode Exit fullscreen mode

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"
            }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
    }
Enter fullscreen mode Exit fullscreen mode

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)