DPoP (Demonstrating Proof of Possession)
Das Problem mit Bearer-Tokens
Die OAuth 2.0 Bearer-Token-Spezifikation (RFC 6750) definiert Tokens, die jedem Zugriff gewähren, der sie besitzt. Das Wort „Bearer” ist wörtlich: Wer das Token trägt (hält), kann es verwenden. Es gibt keine Überprüfung, ob der Präsentierende des Tokens dieselbe Partei ist, für die das Token ausgestellt wurde.
Dies schafft eine Klasse von Angriffen:
| Angriff | Mechanismus |
|---|---|
| Token-Exfiltration via XSS | Bösartiges JavaScript liest das Token aus localStorage und sendet es an den Server eines Angreifers |
| Token-Leck über Logs | Zugriffstoken werden versehentlich von Proxys, CDNs oder Anwendungsservern protokolliert |
| Token-Replay | Ein abgefangenes Token wird von einem anderen Gerät oder einer anderen IP wiedergegeben |
| Token-Diebstahl über kompromittierte Middleware | Ein Reverse-Proxy oder API-Gateway speichert Tokens für späteren Missbrauch |
In all diesen Fällen funktioniert das gestohlene Token in den Händen des Angreifers genauso gut wie in den Händen des legitimen Clients. Der Ressourcenserver kann nicht zwischen beiden unterscheiden, da Bearer-Tokens keinen Beweis darüber tragen, wer sie präsentiert.
Was DPoP tut
DPoP (Demonstrating Proof of Possession), definiert in RFC 9449, löst dieses Problem durch Bindung von Tokens an das kryptographische Schlüsselpaar des Clients. Ein DPoP-gebundenes Token ist ohne den entsprechenden privaten Schlüssel nutzlos.
Die Kernidee:
- Der Client generiert ein ephemeres Schlüsselpaar (RSA oder EC)
- Beim Anfordern eines Tokens erstellt der Client einen DPoP-Proof — ein JWT, das mit dem privaten Schlüssel signiert ist
- Der Autorisierungsserver (Auris) extrahiert den öffentlichen Schlüssel aus dem Proof und bindet das ausgestellte Token an diesen Schlüssel über einen JWK-Thumbprint (
jkt-Claim) - Bei jedem API-Aufruf sendet der Client sowohl das gebundene Zugriffstoken als auch einen frischen DPoP-Proof, der mit demselben privaten Schlüssel signiert ist
- Der Ressourcenserver verifiziert, dass der öffentliche Schlüssel des Proofs mit der
jkt-Bindung des Tokens übereinstimmt
Wenn ein Angreifer das Zugriffstoken, aber nicht den privaten Schlüssel (der im Speicher gehalten und niemals übertragen wird) stiehlt, ist das Token wertlos — der Angreifer kann keine gültigen DPoP-Proofs erstellen.
Funktionsweise: Schritt für Schritt
Schritt 1: Client generiert ein ephemeres Schlüsselpaar
Wenn die Client-Anwendung initialisiert wird, generiert sie ein RSA- oder EC-Schlüsselpaar. Dieses Schlüsselpaar ist ephemer — es existiert nur im Speicher des Clients (oder sicherem Speicher) und wird nie an einen Server gesendet.
// Ein EC-Schlüsselpaar (P-256) über die Web Crypto API generieren
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // nicht-extrahierbar: privater Schlüssel kann nicht exportiert werden
['sign', 'verify']
)Der private Schlüssel ist als nicht-extrahierbar markiert, was bedeutet, dass selbst das eigene JavaScript des Clients das rohe Schlüsselmaterial nicht lesen kann. Es kann nur für Signieroperationen verwendet werden.
Schritt 2: Client erstellt ein DPoP-Proof-JWT
Vor einer Token-Anfrage erstellt der Client einen DPoP-Proof — ein JWT mit spezifischen Claims, signiert mit dem privaten Schlüssel:
// DPoP Proof JWT Header
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "base64url-kodierte-x-koordinate",
"y": "base64url-kodierte-y-koordinate"
}
}
// DPoP Proof JWT Payload
{
"jti": "eindeutige-proof-id-abc123",
"htm": "POST",
"htu": "https://auth.example.com/api/auth/token",
"iat": 1739880000,
"nonce": "server-bereitgestellte-nonce"
}| Claim | Beschreibung |
|---|---|
jti | Ein eindeutiger Bezeichner für diesen Proof (verhindert Replay) |
htm | Die HTTP-Methode der Anfrage, die dieser Proof begleitet |
htu | Die HTTP-URL der Anfrage (ohne Query/Fragment) |
iat | Wann der Proof erstellt wurde (muss aktuell sein) |
nonce | Vom Server bereitgestellte Nonce für Frische (optional, siehe Nonce-Verwaltung) |
jwk (Header) | Der öffentliche Schlüssel entsprechend dem privaten Schlüssel, der zur Signierung des Proofs verwendet wurde |
Der Proof wird mit dem privaten Schlüssel signiert. Der öffentliche Schlüssel ist im jwk-Header eingebettet, damit der Server die Signatur verifizieren kann.
Schritt 3: Client sendet DPoP-Proof mit Token-Anfrage
Der DPoP-Proof wird im DPoP-HTTP-Header zusammen mit der normalen Token-Anfrage gesendet:
POST /api/auth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...
grant_type=authorization_code
&code=auth-code-hier
&redirect_uri=https://app.example.com/callback
&client_id=ihre-client-id
&code_verifier=pkce-verifierSchritt 4: Server bindet das Token an den Schlüssel
Auris validiert den DPoP-Proof:
- Verifiziert, dass der
typ-Headerdpop+jwtist - Verifiziert die Signatur mithilfe des eingebetteten
jwk-öffentlichen Schlüssels - Prüft, dass
htmder Anfragemethode undhtuder Anfrage-URL entspricht - Prüft, dass
iataktuell ist (innerhalb akzeptabler Uhrabweichung) - Prüft
jti-Eindeutigkeit (verhindert Proof-Replay) - Wenn eine Nonce erforderlich war, validiert den
nonce-Claim
Wenn die Validierung erfolgreich ist, berechnet Auris den JWK-Thumbprint (einen Hash des öffentlichen Schlüssels gemäß RFC 7638) und fügt ihn als jkt-Claim in das ausgestellte Zugriffstoken ein:
// Zugriffstoken-Payload (DPoP-gebunden)
{
"sub": "usr_abc123",
"iss": "https://auth.example.com",
"aud": "ihre-client-id",
"iat": 1739880000,
"exp": 1739880900,
"cnf": {
"jkt": "JWK-thumbprint-des-oeffentlichen-schlüssels-des-clients"
},
"token_type": "DPoP"
}Der cnf.jkt-(Confirmation / JWK-Thumbprint-)Claim bindet dieses Token kryptographisch an das Schlüsselpaar des Clients. Der token_type ist auf DPoP anstelle von Bearer gesetzt.
Schritt 5: Client sendet gebundenes Token + frischen Proof bei API-Aufrufen
Bei jedem API-Aufruf sendet der Client sowohl das DPoP-gebundene Zugriffstoken im Authorization-Header als auch einen frischen DPoP-Proof im DPoP-Header:
GET /api/users/me HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJSUzI1NiJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...Das Authorization-Schema ist DPoP, nicht Bearer. Jeder API-Aufruf erfordert einen frischen Proof mit einer eindeutigen jti, dem korrekten htm/htu für die aktuelle Anfrage und einem aktuellen iat.
Schritt 6: Ressourcenserver verifiziert die Bindung
Der Ressourcenserver:
- Extrahiert den DPoP-Proof aus dem
DPoP-Header - Validiert den Proof (Signatur,
htm,htu,iat,jti) - Berechnet den JWK-Thumbprint des
jwk-öffentlichen Schlüssels des Proofs - Vergleicht ihn mit dem
cnf.jkt-Claim im Zugriffstoken - Wenn sie übereinstimmen, hat der Präsentierende den privaten Schlüssel — Proof of Possession bestätigt
Wenn die Thumbprints nicht übereinstimmen, wird die Anfrage mit 401 Unauthorized abgelehnt — das Token wurde für ein anderes Schlüsselpaar ausgestellt, was auf Token-Diebstahl hinweist.
Nonce-Verwaltung
Nonces fügen eine zusätzliche Schicht von Replay-Schutz hinzu. Wenn aktiviert, stellt der Autorisierungsserver eine Nonce bereit, die der Client in den nächsten DPoP-Proof einschließen muss.
Funktionsweise von Nonces
- Client sendet eine Token-Anfrage ohne Nonce
- Server antwortet mit
400und einemDPoP-Nonce-Header mit einem frischen Nonce-Wert - Client wiederholt die Anfrage und schließt die Nonce im
nonce-Claim des DPoP-Proofs ein - Server validiert die Nonce und verarbeitet die Anfrage
- Optional schließt der Server einen neuen
DPoP-Nonce-Header in der Antwort für die nächste Anfrage ein
// Erster Versuch (keine Nonce)
POST /api/auth/token HTTP/1.1
DPoP: eyJ0eXAiOiJkcG9wK2p3dCJ9... // kein nonce-Claim
// Server-Antwort
HTTP/1.1 400 Bad Request
DPoP-Nonce: eyJ2IjoiMSIsImlhdCI6MTczOTg4MDAwMH0
Content-Type: application/json
{"error": "use_dpop_nonce"}
// Zweiter Versuch (mit Nonce)
POST /api/auth/token HTTP/1.1
DPoP: eyJ0eXAiOiJkcG9wK2p3dCJ9... // enthält "nonce": "eyJ2IjoiMSIs..."Warum Nonces helfen
Ohne Nonces ist ein DPoP-Proof gültig, solange sein iat innerhalb des akzeptablen Zeitfensters liegt (typischerweise 60 Sekunden). Ein Angreifer, der den Proof während der Übertragung abfängt, hat ein kurzes Fenster, um ihn wiederzugeben. Nonces eliminieren dieses Fenster: Ein Proof ist nur gültig, wenn er die aktuellste Nonce des Servers enthält, die sich mit jeder Interaktion ändert.
Auris-Nonce-Implementierung
Auris speichert Nonces im DpopNonce-Prisma-Modell mit einer kurzen TTL. Nonces werden als signierte Tokens generiert, die eine Version und einen Zeitstempel enthalten, was es dem Server ermöglicht, sie ohne Datenbankabfrage für den üblichen Fall zu validieren.
Nonce-Verwaltung fügt der ersten Anfrage in einer Sitzung einen zusätzlichen Roundtrip hinzu. Für die meisten Anwendungen bietet die iat-Frischeprüfung allein ausreichenden Replay-Schutz. Aktiviere Nonces nur beim Schutz gegen ausgeklügelte Angreifer auf Netzwerkebene (z. B. in Zero-Trust-Umgebungen oder hochwertigen Finanz-APIs).
DPoP vs mTLS
Sowohl DPoP als auch mTLS (Mutual TLS, RFC 8705) lösen das gleiche grundlegende Problem: Tokens an die Identität des Clients binden. Sie verfolgen unterschiedliche Ansätze:
| Dimension | DPoP (RFC 9449) | mTLS (RFC 8705) |
|---|---|---|
| Bindungsmechanismus | JWK-Thumbprint im Token + signiertes Proof-JWT | Client-Zertifikat-Thumbprint im Token (cnf.x5t#S256) |
| Infrastrukturanforderung | Keine (reine Anwendungsschicht) | TLS-Terminierung muss Client-Zertifikat beibehalten |
| Client-Komplexität | Schlüsselpaar generieren + JWTs signieren | X.509-Zertifikate beschaffen und verwalten |
| Browser-Unterstützung | Funktioniert in Browsern via Web Crypto API | Nicht in Browsern unterstützt |
| Zertifikatsverwaltung | Keine Zertifikate erforderlich | Erfordert PKI oder Zertifizierungsstelle |
| Proxy/CDN-Kompatibilität | Ausgezeichnet (Header passieren durch) | Problematisch (TLS-Terminierung kann Client-Zertifikat entfernen) |
| Leistung | Proof-Generierung ist schnell (EC-Signierung ~1ms) | TLS-Handshake ist aufwändiger, aber amortisiert |
| Token-Diebstahlschutz | Ausgezeichnet | Ausgezeichnet |
Wann DPoP verwenden
- Browser-Anwendungen (SPAs): DPoP ist der einzige Proof-of-Possession-Mechanismus, der in Browsern funktioniert
- Mobile Anwendungen: Einfacher zu implementieren als mTLS-Zertifikatsverwaltung
- Microservice-zu-Microservice: Wenn keine mTLS-Infrastruktur verfügbar ist
- Hinter CDNs/Load-Balancern: DPoP funktioniert durch jeden Proxy, da es HTTP-Header verwendet
Wann mTLS verwenden
- Server-zu-Server mit PKI: Wenn deine Organisation bereits Zertifikatsinfrastruktur hat
- Zero-Trust-Netzwerke: Wo gegenseitige TLS-Authentifizierung auf der Transportschicht erforderlich ist
- Regulatorische Anforderungen: Einige Finanzvorschriften verlangen speziell mTLS
Auris unterstützt DPoP. mTLS wird auf der Keycloak-Schicht für direkte Keycloak-Client-Integrationen unterstützt.
Auris-Implementierungsdetails
DPoP aktivieren
DPoP wird pro Anwendung in der Auris-Konsole konfiguriert. Gehe zu Anwendungen → (Anwendung auswählen) → Einstellungen:
| Einstellung | Beschreibung |
|---|---|
| DPoP aktivieren | Haupt-Toggle für DPoP-Unterstützung dieser Anwendung |
| DPoP verlangen | Wenn aktiviert, lehnt die Anwendung Nicht-DPoP-Token-Anfragen ab |
| Nonces verlangen | Wenn aktiviert, müssen alle DPoP-Proofs eine server-bereitgestellte Nonce enthalten |
Wenn DPoP aktiviert, aber nicht erforderlich ist, akzeptiert die Anwendung sowohl DPoP-gebundene Tokens (Authorization: DPoP ...) als auch einfache Bearer-Tokens (Authorization: Bearer ...). Dies ermöglicht eine schrittweise Migration.
DPoP-gebundene M2M-Tokens
DPoP wird auch für Machine-to-Machine (M2M)-Tokens unterstützt, die über den client_credentials-Grant ausgestellt werden. M2M-Tokens werden im M2MToken-Prisma-Modell mit jkt- (JWK-Thumbprint) und isDpopBound-Feldern gespeichert.
POST /api/auth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCJ9...
grant_type=client_credentials
&client_id=m2m-client-id
&client_secret=m2m-client-secret
&scope=read:usersDas resultierende M2M-Token ist an das DPoP-Schlüsselpaar gebunden und bietet Proof-of-Possession für Service-zu-Service-Kommunikation.
DPoP-Proof-Validierung
Auris validiert DPoP-Proofs in der dpop-validator.ts-Middleware mit folgenden Prüfungen:
| Prüfung | Fehlerantwort |
|---|---|
typ-Header muss dpop+jwt sein | 400 invalid_dpop_proof |
jwk-Header muss einen gültigen öffentlichen Schlüssel enthalten | 400 invalid_dpop_proof |
Signatur muss mit dem eingebetteten jwk gültig sein | 400 invalid_dpop_proof |
htm muss der HTTP-Methode der aktuellen Anfrage entsprechen | 400 invalid_dpop_proof |
htu muss der URL der aktuellen Anfrage entsprechen (Schema + Host + Pfad) | 400 invalid_dpop_proof |
iat muss innerhalb von 60 Sekunden der Server-Zeit liegen | 400 invalid_dpop_proof |
jti darf nicht zuvor verwendet worden sein (Replay-Prüfung) | 400 invalid_dpop_proof |
nonce muss der Nonce des Servers entsprechen (wenn Nonces erforderlich) | 400 use_dpop_nonce (mit DPoP-Nonce-Header) |
Codebeispiel: DPoP-Proofs in TypeScript generieren
Das folgende Beispiel demonstriert den vollständigen DPoP-Flow über die Web Crypto API, geeignet für Browser- und Node.js-Umgebungen:
import { SignJWT, exportJWK, calculateJwkThumbprint } from 'jose'
// Schritt 1: Ein ephemeres EC-Schlüsselpaar generieren
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
)
// Schritt 2: Öffentlichen Schlüssel als JWK exportieren (für den Proof-Header)
const publicJwk = await exportJWK(keyPair.publicKey)
publicJwk.alg = 'ES256'
// Schritt 3: DPoP-Proof für eine Token-Anfrage erstellen
async function createDpopProof(
method: string,
url: string,
nonce?: string
): Promise<string> {
const builder = new SignJWT({
htm: method,
htu: url,
...(nonce ? { nonce } : {}),
})
.setProtectedHeader({
typ: 'dpop+jwt',
alg: 'ES256',
jwk: publicJwk,
})
.setJti(crypto.randomUUID())
.setIssuedAt()
return builder.sign(keyPair.privateKey)
}
// Schritt 4: DPoP-gebundenes Token anfordern
const tokenUrl = 'https://auth.example.com/api/auth/token'
const dpopProof = await createDpopProof('POST', tokenUrl)
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': dpopProof,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'auth-code',
redirect_uri: 'https://app.example.com/callback',
client_id: 'ihre-client-id',
code_verifier: 'pkce-verifier',
}),
})
// Nonce-Anforderung behandeln
if (response.status === 400) {
const nonce = response.headers.get('DPoP-Nonce')
if (nonce) {
// Wiederholung mit Nonce
const retryProof = await createDpopProof('POST', tokenUrl, nonce)
// ... Anfrage mit retryProof wiederholen
}
}
const { accessToken } = await response.json()
// Schritt 5: DPoP-gebundenes Token bei API-Aufrufen verwenden
const apiUrl = 'https://api.example.com/users/me'
const apiProof = await createDpopProof('GET', apiUrl)
const apiResponse = await fetch(apiUrl, {
headers: {
'Authorization': `DPoP ${accessToken}`,
'DPoP': apiProof,
},
})Jeder DPoP-Proof muss eine eindeutige jti und ein frisches iat haben. Proofs niemals über Anfragen hinweg wiederverwenden. Das htm und htu müssen genau mit der Anfrage übereinstimmen — ein für GET /api/users erstellter Proof kann nicht für POST /api/users oder GET /api/roles verwendet werden.
Sicherheitseigenschaften
DPoP bietet folgende Sicherheitsgarantien:
| Eigenschaft | Wie sie erreicht wird |
|---|---|
| Token-Bindung | cnf.jkt des Zugriffstokens stimmt mit dem jwk-Thumbprint des Proofs überein |
| Replay-Schutz | Jeder Proof hat eine eindeutige jti und aktuelles iat; Server-seitige Nonces fügen extra Frische hinzu |
| Methoden/URL-Bindung | htm und htu verhindern Proof-Wiederverwendung über verschiedene Endpunkte hinweg |
| Schlüssel-Nicht-Exfiltrierbarkeit | Privater Schlüssel wird mit extractable: false in Web Crypto generiert |
| Forward Secrecy | Ephemere Schlüsselpaare bedeuten, dass die Kompromittierung des Schlüssels einer Sitzung keine anderen Sitzungen betrifft |
DPoP schützt nicht gegen:
- XSS mit vollständiger Code-Ausführung: Wenn ein Angreifer beliebiges JavaScript im gleichen Origin ausführen kann, kann er
crypto.subtle.sign()mit dem nicht-extrahierbaren Schlüssel aufrufen, um gültige Proofs zu erstellen. Er kann den Schlüssel jedoch nicht für die Verwendung außerhalb des Browsers exfiltrieren. - Kompromittierte Client-Binärdatei: Wenn die Client-Anwendung selbst mit einer Backdoor versehen ist, bietet DPoP keinen zusätzlichen Schutz.
Verwandte Konzepte
- Tokens erklärt — JWT-Struktur, Signierung und Verifizierung
- OAuth 2.0 & OIDC — Das Autorisierungsframework, das DPoP erweitert
- M2M-Client-Anmeldedaten — DPoP-gebundene Service-Tokens
- Anwendungen — DPoP pro Anwendung in der Konsole aktivieren