This blog post is also available in English

  • Ein Bearer Token ist ein Risiko: Stiehl ihn aus einem Log, einem Trace oder einem kompromittierten Client, und die API gehört dir.
  • DPoP (RFC 9449) bindet den Access Token an das Schlüsselpaar des Clients, sodass ein gestohlener Token ohne den privaten Schlüssel nutzlos ist.
  • Der schwierige Teil liegt beim Resource Server (Proof verifizieren, Replays blocken, standardmäßig ablehnen), und ein Proxy wie Heimdall übernimmt das von Haus aus.
  • Über DPoP hinaus bekommst du mehr gratis: IdP-Abstraktion und Defense in Depth, plus ein Muster, das für KI-Agenten wichtig ist (MCP SEP-1932).

Beim letzten INNOQ Technology Day habe ich einen Vortrag über OAuth 2.1 gehalten. Gegen Ende fragte jemand: „Der Proof of Possession würde mich sehr interessieren. Kannst du dazu was sagen?"

Meine Antwort fiel damals etwas zu knapp aus. Jetzt, wo die Aufzeichnung seit einigen Wochen auf YouTube steht, will ich diese Frage ausführlicher beantworten – mit einem konkreten Beispiel, das man auch selbst ausprobieren kann.

Wie aus meiner Antwort hoffentlich hervorging (Zeitmarke 47:13 im Video), ist die DPoP-Unterstützung im Identity Provider und im Client nur die halbe Niete. Der Resource Server ist natürlich mit im Spiel und muss bei jeder eingehenden Anfrage den Proof verifizieren, Replay-Angriffe erkennen und standardmäßig alles ablehnen, was nicht ausdrücklich erlaubt ist. Das will man nicht in jedem einzelnen Service selbst implementieren. Für genau solche Zwecke kann ein Proxy sehr nützlich sein. Und soweit ich weiß, ist Heimdall - der Open-Source-Proxy, den ich pflege - der erste, der das nativ unterstützt.

Lasst uns aber zuerst einen Blick auf das eigentliche Problem werfen.

Ein Bearer Token ist genau das, was der Name sagt: Wer ihn besitzt (bear), kommt rein. Wird der Token aus einer Logdatei, einem Netzwerk-Trace oder einem kompromittierten Client gestohlen, gehört dem Angreifer die API – ganz ohne jegliche Gegenwehr.

DPoP (RFC 9449) adressiert dieses Probelm das mit den s.g. sender-constrained Access Tokens: Statt einen Token einfach vorzuzeigen, muss der Client bei jeder Anfrage beweisen, dass er den privaten Schlüssel besitzt, für den der Token ausgestellt wurde. Ein gestohlener Token ist ohne diesen privaten Schlüssel wertlos.

Wie DPoP tatsächlich funktioniert

DPoP bindet einen Access Token an ein bestimmtes Schlüsselpaar. Die Grundidee ist wie folgt:

Der Client erzeugt ein asymmetrisches Schlüsselpaar - typischerweise EC P-256. Beim Anfordern eines Tokens sendet er den öffentlichen Schlüssel eingebettet in ein selbstsigniertes JWT an den Authorization Server. Der Server prüft diese Signatur, bettet den Fingerprint des öffentlichen Schlüssels als cnf-Claim (confirmation) in den Access Token ein und stellt diesen aus.

Bei jedem API-Aufruf muss der Client nun nachweisen, dass er den zugehörigen privaten Schlüssel noch besitzt – unter Verwendung eines signierten Proof-JWTs, des DPoP-Proofs. Dieser enthält:

  • htm: die HTTP-Methode der Anfrage
  • htu: die URL der Anfrage
  • jti: einen eindeutigen Identifier gegen Replay-Angriffe
  • ath: einen Hash des Access Tokens, auf den sich der Proof bezieht

Der Resource Server prüft, ob der Proof mit dem im cnf-Claim referenzierten Schlüssel signiert wurde, ob er zur aktuellen Anfrage passt und ob er nicht bereits einmal verwendet wurde. Ein gestohlener Token bleibt ohne den privaten Schlüssel nutzlos, denn einen gültigen Proof kann man ohne diesen nicht fälschen bzw erzeugen. Der Resource Server kann zusätzlich eine Nonce im Proof verlangen – einen kurzlebigen Wert, der das Gültigkeitsfenster jedes Proofs weiter einschränkt. Dazu weiter unten mehr.

Das Setup

Vier Komponenten, die lokal über Docker Compose aufgesetzt werden:

Keycloak       → stellt DPoP-gebundene Access Tokens aus
DPoP Client    → weist bei jeder Anfrage den Besitz nach
Heimdall       → validiert Token + Proof, setzt Deny-by-Default durch
traefik/whoami → simuliert den Upstream-Service (den Resource Server)

Heimdall sitzt vor der API. Der Upstream-Service selbst weiß nichts von DPoP – er bekommt nur bereits verifizierte Anfragen und kümmert sich um seine eigentliche Aufgabe.

Der Ablauf

1. Client generiert ein EC-Schlüsselpaar (P-256)
2. Client führt den Authorization Code Grant Flow + PKCE unter Verwendung eines selbstsignierten JWTs aus
3. Keycloak stellt einen Access Token aus, der an den öffentlichen Schlüssel des Clients gebunden ist (cnf-Claim)
4. Client ruft die API auf:
   Authorization: DPoP <access_token>
   DPoP: <signierter Proof-JWT>
5. Heimdall prüft:
   - Gültigkeit des Access Tokens (Issuer, Ablaufzeit, Algorithmen)
   - ob der Proof zum Token passt (ath-Claim)
   - ob der Proof zur aktuellen Anfrage passt (htm-, htu-Claims)
   - ob der Proof schon einmal verwendet wurde (jti-Replay-Check)
   - ob die Nonce noch frisch ist (DPoP-Nonce-Challenge)
6. Alle Prüfungen bestanden → Anfrage wird an den Upstream weitergeleitet

Die Heimdall-Konfiguration

Zwei Dateien genügen. Die erste definiert die Security-Mechanismen - wie Tokens validiert und wie interne Tokens ausgestellt werden.

secret_management:
  nonce_keys:
    type: jwks
    config:
      path: /etc/heimdall/secrets.jwks
  signing_keys:
    type: pem
    config:
      path: /etc/heimdall/signer.pem
# verweist auf einen oben definierten Schlüssel, der zur Erzeugung der 
# DPoP-Nonce genutzt wird
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
# Per default werden alle Requests abgelehnt
default_rule:
  execute:
    - authenticator: deny_all
    - finalizer: internal_token

Besonders interessant ist der Abschnitt proof_of_possession:":

proof_of_possession:
  type: dpop
  config:
    max_age: 1m
    nonce_required: true
    replay_allowed: false

Hier findet das eigentliche DPoP-Enforcement statt: max_age begrenzt, wie lange ein Proof gültig ist, nonce_required zwingt den Client, bei jeder Anfrage eine frische, vom Server ausgestellte Nonce mitzuschicken, und replay_allowed: false sorgt dafür, dass jeder Proof nur genau einmal verwendet werden kann.

Die zweite Datei ist Upstream-Service-spezifisch. In dieser werden Routen diesen Mechanismen zugeordnet und falls notwendig umkonfiguriert.

# rules/upstream-rules.yaml
rules:
  # Erlaube anfragen an /api und /api/*, die einen dpop bound Access Token
  # mitführen und leite diese an upstream:8081 weiter
  - 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"
            }

Das wär’s. Der Upstream bekommt ausschließlich verifizierte Anfragen – keine Auth-Logik, keine DPoP-Prüfung, nichts, worum er sich kümmern müsste. Na ja, bis auf die Verifikation des internen Tokens, der vom Heimdall in diesem Beispiel ausgestellt wird.

Die Nonce-Challenge

Die erste Anfrage an einen geschützten Endpunkt wird mit der folgenden Challenge abgewiesen:

HTTP 401
WWW-Authenticate: DPoP error="use_dpop_nonce"
DPoP-Nonce: <fresh nonce>

Der Client nimmt die Challenge Nonce in seinen nächsten Proof auf. Damit kann man das Replay-Fenster von „Lebensdauer des Tokens" auf eine Minute schrumpfen lassen. Und dieser Wert lässt sich auch konfigurieren. Die eigentliche Nonceprüfung und -erzeugung erfolgt in Heimdall mittels eines symmetrischen Schlüssels (aus secret_management). Der komplette Nonce-Lebenszyklus bleibt somit innerhalb des Proxys.

Was Heimdall abfängt

Angriff Wie er gestoppt wird
Gestohlener Token cnf-Claim stimmt nicht überein – nicht an den Schlüssel des Angreifers gebunden
Wiederholter DPoP-Proof jti wurde bereits gesehen, replay_allowed: false
Proof für falschen Endpunkt htu stimmt nicht überein
Proof für falsche HTTP Methode htm stimmt nicht überein
Veralteter Proof max_age: 1m überschritten
Falscher Token im Proof ath stimmt nicht überein

Mehr als nur DPoP: Was man darüberhinaus bekommt

Abstraktion vom Identity Providern

In Produktivsystemen werden Identity Provider im Laufe der Zeit verändert. Unternehmen konsolidieren Systeme, migrieren zu anderen Anbietern oder betreiben mehrere Systeme parallel. So führt die Validierung des Tokens direkt gegen den JWKS-Endpunkt von z.B. Keycloak dazu, dass jede Migration jeden einzelnen Service betrifft.

Es geht auch anders: Heimdall als einziger Issuer, dem der Upstream vertraut.

Nach der Validierung des DPoP-gebundenen Tokens (Nonce-Prüfung, Replay-Schutz, Proof of Possession) stellt Heimdall ein frisches internes JWT mit normalisierten Claims aus, die der Upstream-Service erwartet:

finalizer: internal_token
config:
  claims: |
    {
      "url": {{ quote .Request.URL.String }},
      "service": "upstream"
    }

Der Upstream validiert ausschließlich gegen den JWKS-Endpunkt von Heimdall – sonst nichts. Ob der ursprüngliche Token DPoP-gebunden war, welcher IdP ihn ausgestellt hat oder welcher Grant-Flow zum Einsatz kam, spielt keine Rolle mehr. Wird Keycloak gegen Okta oder Azure AD ausgetauscht, bleibt die Upstream-Konfiguration unverändert.

Defense in Depth

Validierung am Perimeter ist wichtig, reicht allein aber nicht aus. Denn ein kompromittierter interner Service kann ohne weitere Maßnahmen jeden Upstream erreichen, der beliebige JWTs akzeptiert. Genau hier setzt der interne Token an: Der Upstream akzeptiert nur Tokens, die von Heimdall ausgestellt wurden und die erwarteten Claims enthält. Ein Angreifer im internen Netzwerk kann ohne weiteres keinen gültigen Heimdall-Token erzeugen. Die Signaturprüfung des Tokens wird also fehlschlagen und die Anfrage abgelehnt. Das ist der entscheidende Unterschied zwischen Authentifizierung am Perimeter und Authentifizierung bei jedem einzelnen Hop.

DPoP und KI-Agenten

Bearer Tokens und KI-Agenten sind eine schlechte Kombi. Ein Agent, der über Service-Grenzen hinweg agiert – MCP-Tools aufruft, an Sub-Agenten delegiert, über Gateways routet – kann Tokens ständig über Logs, Traces und Intermediaries leaken. DPoP bindet den Token an das Schlüsselpaar des Agenten: Wie schon oben geschrieben, ist ein abgefangener Token, ohne des dazugeörigen privaten Schlüssels, nutzlos. Das ist unter Anderem auch der Grund wieso DPoP aktuell in MCP SEP-1932 als Proof-of-Possession-Mechanismus für die Authentifizierung von KI-Agenten diskutiert wird – und Heimdall es bereits unterstützt.

Probier es selbst aus

Ein vollständiges, lauffähiges Beispiel mit Docker Compose, Keycloak-Setup und DPoP-Client gibt es hier:

dadrus.github.io/heimdall/dev/guides/authn/dpop_bound_access_tokens

Der Guide bezieht sich auf das dev-Image (gebaut aus dem main-Branch, verfügbar auf GHCR und Docker Hub), da DPoP-Unterstützung noch nicht in einem stabilen Release gelandet ist.