Zum Inhalt springen
ecdsa.com
← Alle Artikel

DPoP erklärt: OAuth-Tokens an den Client-Schlüssel binden

Ein Bearer Token funktioniert für jeden, der es besitzt. Ein DPoP-gebundenes Token funktioniert nur zusammen mit einer frischen Signatur, die mit dem privaten Schlüssel des Clients erstellt wurde; ein kopiertes Token allein ist also wertlos. Hier findest du das Protokoll, Client- und Servercode und das, was ein Ressourcenserver ablehnte, als von Keycloak ausgestellte Tokens und Proofs gestohlen, erneut eingespielt und wiederverwendet wurden.

Kurz gesagt. DPoP (Demonstrating Proof of Possession, RFC 9449, September 2023) bindet ein Access Token an ein Schlüsselpaar, das der Client erzeugt. Der Autorisierungsserver hinterlegt den Thumbprint des öffentlichen Schlüssels im Token (cnf.jkt) und antwortet mit token_type: DPoP. Bei jeder Anfrage sendet der Client das Token mit dem Schema DPoP und dazu einen Header DPoP: ein kurzes, mit dem privaten Schlüssel signiertes JWT, das die HTTP-Methode (htm), die URL (htu), eine eindeutige jti, den Zeitpunkt (iat) und den Hash des Access Tokens (ath) nennt. Der Ressourcenserver prüft den Proof, lehnt wiederverwendete Werte von jti und alte Proofs ab und stellt sicher, dass der Schlüssel des Proofs derjenige ist, an den das Token gebunden ist. Setze DPoP ein, wenn Access Tokens oder Refresh Tokens an Orten liegen, von denen sie sich kopieren lassen, etwa in Browsern und mobilen Apps, oder wenn ein Profil wie FAPI 2.0 sendergebundene Tokens verlangt.

Welches Problem DPoP löst

Ein OAuth-Bearer-Token (RFC 6750) ist wie Bargeld: Wer es vorlegt, bekommt Zugriff. Tokens sickern über Logs, den Browser-Speicher, Proxys, Absturzberichte und kompromittierte Abhängigkeiten nach außen, und ein so abgeflossenes Bearer Token funktioniert von überall aus, bis es abläuft. Sendergebundene Tokens (Sender-Constrained Tokens) ergänzen einen zweiten Faktor, der nicht zusammen mit dem Token weitergegeben wird. OAuth kennt dafür zwei standardisierte Wege: Mutual TLS (RFC 8705), das das Token an ein Client-Zertifikat bindet, und DPoP, das es an einen Schlüssel auf der Anwendungsschicht bindet. DPoP braucht keine TLS-Client-Zertifikate, funktioniert aus dem Browser heraus und durch TLS-terminierende Proxys hindurch und kostet eine Signatur pro Anfrage.

Was DPoP nicht leistet: Es signiert weder den Body noch die Header der Anfrage, sondern nur Methode und URL (§4.2), und es hilft nicht, wenn ein Angreifer Code im Client ausführen kann, denn dieser Code kann mit dem Schlüssel des Clients Proofs signieren lassen (§11.4). Wie du ganze Anfragen signierst, zeigt HTTP Message Signatures.

Der Ablauf

  1. Der Client erzeugt ein Schlüsselpaar, idealerweise nicht exportierbar, und behält es für die Lebensdauer seiner Tokens.
  2. Token-Anfrage. Der Client sendet seine normale Token-Anfrage mit einem Header DPoP, der einen Proof für POST und die URL des Token-Endpunkts enthält. Verlangt der Autorisierungsserver eine Nonce, antwortet er mit 400, use_dpop_nonce und einem Header DPoP-Nonce, und der Client wiederholt die Anfrage mit dieser Nonce im Proof.
  3. Token-Antwort. token_type ist DPoP, nicht Bearer. Ein Access Token im JWT-Format enthält "cnf": {"jkt": "<SHA-256 JWK thumbprint>"} (§6.1, RFC 7638); bei einem opaken Token liefert die Introspection denselben Wert (§6.2). Refresh Tokens, die an öffentliche Clients ausgegeben werden, sind ebenfalls an den Schlüssel gebunden (§5).
  4. Ressourcenanfrage. Authorization: DPoP <access token> plus ein neuer Proof für diese Methode und URL, jetzt mit ath, dem base64url-kodierten SHA-256-Hash des Access Tokens.
  5. Der Ressourcenserver prüft den Proof und die Bindung und kann seinerseits mit 401 und WWW-Authenticate: DPoP error="use_dpop_nonce" eine Nonce verlangen (§9).

Ein Proof enthält diese Felder (§4.2):

TeilFeldBedeutung
Headertypdpop+jwt, damit ein Proof nie mit einem anderen JWT verwechselt werden kann
Headeralgnur asymmetrisch; nie none oder HS*
Headerjwkder öffentliche Schlüssel des Clients; nie ein privater Schlüssel
Payloadjtieindeutig pro Proof: mindestens 96 Bit Zufall oder eine UUID v4
Payloadhtm, htuHTTP-Methode sowie Ziel-URI ohne Query und Fragment
PayloadiatErstellungszeitpunkt
PayloadathHash des Access Tokens; Pflicht, wenn eines vorgelegt wird
Payloadnoncedie letzte DPoP-Nonce des Servers, falls er eine gesendet hat

Client: Proofs erzeugen

dpop-client.mjs (jose 6.2.13, Node.js 24.22)
import { randomUUID, createHash } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair } from "jose";

// One key pair per client instance. The private key is a non-extractable CryptoKey:
// code that can call sign() can make proofs, but cannot copy the key elsewhere.
const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);

export const ath = (accessToken) => createHash("sha256").update(accessToken, "ascii").digest("base64url");

/** A DPoP proof for one request (RFC 9449 §4.2). */
export function dpopProof({ method, url, accessToken, nonce }) {
  const target = new URL(url);
  return new SignJWT({
    jti: randomUUID(),
    htm: method,
    htu: target.origin + target.pathname, // without query and fragment
    ...(accessToken && { ath: ath(accessToken) }),
    ...(nonce && { nonce }),
  })
    .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
    .setIssuedAt()
    .sign(privateKey);
}

/** Calls fetch with a fresh proof; retries once when the server asks for a nonce. */
export async function dpopFetch(url, { method = "GET", accessToken, headers = {}, body } = {}, state = {}) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const proof = await dpopProof({ method, url, accessToken, nonce: state.nonce });
    const res = await fetch(url, {
      method,
      body,
      headers: { ...headers, DPoP: proof, ...(accessToken && { Authorization: `DPoP ${accessToken}` }) },
    });
    if (res.headers.has("dpop-nonce")) state.nonce = res.headers.get("dpop-nonce");
    const challenge = res.headers.get("www-authenticate") ?? "";
    const bodyText = res.status === 400 ? await res.clone().text() : "";
    const askedForNonce = challenge.includes('error="use_dpop_nonce"') || bodyText.includes('"use_dpop_nonce"');
    if (!askedForNonce || attempt === 1) return res;
  }
}

Im Browser funktioniert derselbe Ansatz mit der Web Crypto API: Ein mit extractable: false erzeugter Schlüssel lässt sich in IndexedDB speichern und zum Signieren verwenden, aber kein Skript kann seinen privaten Teil auslesen. Das schützt vor dem Diebstahl des Schlüssels, nicht aber vor einem bösartigen Skript auf der Seite, das ihn zum Signieren benutzt.

Ressourcenserver: Proofs prüfen

RFC 9449 §4.3 zählt zwölf Prüfungen auf, die laut RFC „in beliebiger Reihenfolge durchgeführt werden können“, und §7 ergänzt die Token-Bindung. Der folgende Verifizierer setzt sie für eine Anfrage um, mit den Schrittnummern in den Kommentaren:

dpop-verify.mjs (jose 6.2.13)
import { createHash, randomBytes } from "node:crypto";
import { EmbeddedJWK, calculateJwkThumbprint, createRemoteJWKSet, jwtVerify } from "jose";

const PROOF_ALGS = ["ES256", "EdDSA", "PS256"]; // asymmetric only; never none or HS*
const MAX_PROOF_AGE = 60; // seconds
const SKEW = 5; // seconds a client clock may run ahead

export class DpopError extends Error {
  constructor(error, description, headers = {}) {
    super(description);
    this.error = error; // invalid_token | invalid_dpop_proof | use_dpop_nonce
    this.headers = headers;
  }
}

const sha256 = (text) => createHash("sha256").update(text, "ascii").digest("base64url");
const normalizeHtu = (htu) => {
  const u = new URL(htu); // lowercases scheme and host, drops default ports (RFC 3986 §6.2.2-6.2.3)
  return u.origin + u.pathname;
};

/** Resource-server side of RFC 9449: checks §4.3 and §7 for one request. */
export function createDpopVerifier({ issuer, jwksUri, audience, publicOrigin, requireNonce }) {
  const jwks = createRemoteJWKSet(new URL(jwksUri));
  const seen = new Map(); // "htu jti" -> expiry, for replay detection (§11.1)
  const nonce = randomBytes(16).toString("base64url"); // rotate it on a timer in production

  return async function verify(req) {
    // §7.1: a DPoP-bound access token arrives with the DPoP scheme, never as Bearer (§7.2).
    const [scheme, accessToken, ...extra] = (req.headers.authorization ?? "").split(" ");
    if (scheme !== "DPoP" || !accessToken || extra.length > 0) {
      throw new DpopError("invalid_token", "DPoP authorization scheme required");
    }
    // Steps 1-2: exactly one DPoP header holding one JWT.
    const proofs = req.headersDistinct.dpop ?? [];
    if (proofs.length !== 1) throw new DpopError("invalid_dpop_proof", `${proofs.length} DPoP headers`);
    // Steps 3-7 and 11: claims present, typ, asymmetric alg, signature with the embedded
    // public key (EmbeddedJWK refuses private keys), and iat within the window.
    let proof;
    try {
      proof = await jwtVerify(proofs[0], EmbeddedJWK, {
        typ: "dpop+jwt",
        algorithms: PROOF_ALGS,
        requiredClaims: ["jti", "htm", "htu", "iat", "ath"],
        maxTokenAge: MAX_PROOF_AGE,
        clockTolerance: SKEW,
      });
    } catch (e) {
      throw new DpopError("invalid_dpop_proof", e.message);
    }
    const { payload: p, protectedHeader } = proof;
    // Steps 8-9: the proof was made for this method and this URI.
    const target = new URL(req.url, publicOrigin);
    if (p.htm !== req.method) throw new DpopError("invalid_dpop_proof", `htm ${p.htm} != ${req.method}`);
    if (normalizeHtu(p.htu) !== target.origin + target.pathname) {
      throw new DpopError("invalid_dpop_proof", `htu ${p.htu} != ${target.origin}${target.pathname}`);
    }
    // Step 10: the server's current nonce, if this server hands them out.
    if (requireNonce && p.nonce !== nonce) {
      throw new DpopError("use_dpop_nonce", "Resource server requires nonce in DPoP proof", { "DPoP-Nonce": nonce });
    }
    // Replay: each jti once per target URI while the proof could still be accepted.
    const key = `${p.htu} ${p.jti}`;
    const now = Date.now();
    for (const [k, expiry] of seen) if (expiry < now) seen.delete(k);
    if (seen.has(key)) throw new DpopError("invalid_dpop_proof", "DPoP proof already used");
    seen.set(key, (p.iat + MAX_PROOF_AGE + SKEW) * 1000);
    // The access token itself: issuer, audience, signature, expiry.
    let token;
    try {
      ({ payload: token } = await jwtVerify(accessToken, jwks, { issuer, audience, algorithms: ["RS256"] }));
    } catch (e) {
      throw new DpopError("invalid_token", e.message);
    }
    // Step 12: the proof belongs to this token, and the token is bound to the proof's key.
    if (p.ath !== sha256(accessToken)) throw new DpopError("invalid_dpop_proof", "ath does not match the access token");
    if (token.cnf?.jkt !== (await calculateJwkThumbprint(protectedHeader.jwk, "sha256"))) {
      throw new DpopError("invalid_token", "Invalid DPoP key binding");
    }
    return token;
  };
}

/** Turns a DpopError into the 401 response RFC 9449 §7.1 describes. */
export function challenge(err) {
  const description = err.message.replaceAll('"', "'");
  return {
    status: 401,
    headers: {
      "WWW-Authenticate": `DPoP error="${err.error}", error_description="${description}", algs="${PROOF_ALGS.join(" ")}"`,
      ...err.headers,
    },
  };
}

Drei Details sind wichtiger, als es scheint:

  • publicOrigin kommt aus der Konfiguration, nicht aus dem Header Host. Hinter einem Load Balancer sieht der Server eine interne URL. Wer die URL aus den Headern der Anfrage rekonstruiert, lässt entweder jede Anfrage scheitern oder vertraut einem Header, den der Client bestimmt.
  • Der Replay-Speicher gilt pro Ziel-URI und nur für die Dauer des Proof-Fensters. Laufen mehrere Instanzen hinter einem Endpunkt, müssen sie den Speicher teilen, und RFC 9449 räumt ein, dass das „möglicherweise nicht immer machbar“ ist; die Alternative sind kurzlebige Server-Nonces.
  • Lehne ein DPoP-gebundenes Token ab, das als Bearer gesendet wird (§7.2). Sonst ist die Bindung für jeden optional, der das Token gestohlen hat.

Getestet mit Tokens aus Keycloak

Im Testaufbau lief Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, Dev-Modus) mit einem vertraulichen Client, orders-cli, der so eingestellt war, dass er DPoP-gebundene Access Tokens verlangt (das Client-Attribut dpop.bound.access.tokens), und mit einem Audience-Mapper, der orders-api hinzufügt. Ein Treiberskript forderte Tokens mit Proofs aus dpop-client.mjs an, startete auf Port 4000 einen Ressourcenserver auf Basis von dpop-verify.mjs mit eingeschalteten Nonces und probierte dann aus, was ein Angreifer mit einem kopierten Token oder einer kopierten Anfrage versuchen würde:

Ausgabe: Keycloak 26.8.1 und der Ressourcenserver oben (node:24.22.0-bookworm-slim)
dpop_signing_alg_values_supported: PS384 RS384 EdDSA ES384 ES256 RS256 ES512 PS256 PS512 RS512
token request, proof with htm GET: 400 {"error":"invalid_request","error_description":"DPoP HTTP method mismatch"}
token request without DPoP header: 400 {"error":"invalid_request","error_description":"DPoP proof is missing"}
token response: 200, token_type DPoP
access token aud ["orders-api","account"], cnf {"jkt":"jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k","kc-jkt-type":"DPoP"}
thumbprint of the client's DPoP key: jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k
first call, no nonce yet                     401 DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof", algs="ES256 EdDSA PS256"
retry with the server's nonce                200 {"orders":[],"client":"orders-cli"}
proof sent: {"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","x":"M5K5xM809NM5egv0kblUzobF_h2cNudX-6ENCI0MjiQ","y":"qtatemMO_oibrlk6VpoXDbHPtCiZNksoyVMcIGRx33w","crv":"P-256"}} {"jti":"993999b4-d7f8-4018-9f8e-e20f2a109a71","htm":"GET","htu":"http://127.0.0.1:4000/orders","ath":"CFA2MS0HVrzwNCH1DJZgOjreMZ0qSfyrNWXR6W-rKb8","nonce":"ItQnwia8Goi2bVqV4toJFQ","iat":1792497394}
dpopFetch, no nonce known (2 requests)       200 {"orders":[],"client":"orders-cli"}
same proof replayed                          401 DPoP error="invalid_dpop_proof", error_description="DPoP proof already used", algs="ES256 EdDSA PS256"
stolen token sent as Bearer                  401 DPoP error="invalid_token", error_description="DPoP authorization scheme required", algs="ES256 EdDSA PS256"
stolen token, attacker's own key             401 DPoP error="invalid_token", error_description="Invalid DPoP key binding", algs="ES256 EdDSA PS256"
GET proof used for DELETE                    401 DPoP error="invalid_dpop_proof", error_description="htm GET != DELETE", algs="ES256 EdDSA PS256"
proof made for another access token          401 DPoP error="invalid_dpop_proof", error_description="ath does not match the access token", algs="ES256 EdDSA PS256"
proof created two minutes ago                401 DPoP error="invalid_dpop_proof", error_description="'iat' claim timestamp check failed (too far in the past)", algs="ES256 EdDSA PS256"

Was der Durchlauf zeigt:

  • Die Bindung greift. Keycloak lieferte token_type DPoP und einen cnf.jkt, der dem Thumbprint des Client-Schlüssels entspricht. Zusätzlich fügt Keycloak neben jkt ein nicht standardisiertes Feld hinzu, kc-jkt-type; Ressourcenserver müssen Felder ignorieren, die sie nicht kennen, wie es der Verifizierer tut.
  • Ein gestohlenes Token reicht nicht. Als Bearer gesendet, wurde es abgelehnt; mit einem gültigen Proof vom eigenen Schlüssel des Angreifers scheiterte es an der Bindungsprüfung. Auch eine kopierte Anfrage scheiterte: Derselbe Proof ließ sich nicht erneut einspielen, ein Proof für GET funktionierte nicht für DELETE, und ein zwei Minuten zuvor erstellter Proof war zu alt.
  • ath bindet einen Proof an genau ein Token. Ein gültiger Proof, erstellt für ein anderes Access Token desselben Clients mit demselben Schlüssel, wurde abgelehnt.
  • Nonces kosten einen Roundtrip. Die erste Anfrage ohne Nonce bekam 401 use_dpop_nonce; dpopFetch brauchte beim ersten Aufruf 2 Anfragen, danach 1 Anfrage.

Auch der Token-Endpunkt von Keycloak setzt die Proof-Regeln durch. Ein zweites kurzes Skript, das das Client-Modul wiederverwendet:

as-replay.mjs
// Does Keycloak's token endpoint accept the same DPoP proof twice? And a proof with a wrong htu?
import { dpopProof } from "./dpop-client.mjs";

const ISSUER = "http://127.0.0.1:8080/realms/demo";
const { token_endpoint } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const send = (proof) =>
  fetch(token_endpoint, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: `Basic ${Buffer.from(`orders-cli:${process.env.CLI}`).toString("base64")}`,
      DPoP: proof,
    },
    body: "grant_type=client_credentials",
  });
const proof = await dpopProof({ method: "POST", url: token_endpoint });
for (const label of ["first use of a proof", "same proof again"]) {
  const res = await send(proof);
  const body = await res.json();
  console.log(`${label.padEnd(24)} ${res.status} ${body.token_type ?? JSON.stringify(body)}`);
}
const wrongUri = await send(await dpopProof({ method: "POST", url: `${ISSUER}/protocol/openid-connect/userinfo` }));
console.log(`${"proof for another URI".padEnd(24)} ${wrongUri.status} ${await wrongUri.text()}`);
Ausgabe
first use of a proof     200 DPoP
same proof again         400 {"error":"invalid_request","error_description":"DPoP proof has already been used"}
proof for another URI    400 {"error":"invalid_request","error_description":"DPoP HTTP URL mismatch"}

Eine Abweichung solltest du kennen: Laut RFC 9449 §5 bekommt ein ungültiger Proof am Token-Endpunkt den Fehlercode invalid_dpop_proof, Keycloak 26.8.1 antwortete aber bei jedem der obigen Proof-Probleme mit invalid_request. Schreibe Clients so, dass sie nur bei use_dpop_nonce verzweigen, und behandle andere Fehler einer DPoP-Token-Anfrage als Fehlschläge, die du meldest, statt als Codes, die du auswertest.

Nonces: warum Server sie verlangen

Ohne Server-Nonce hängt die Aktualität eines Proofs an iat, einem Wert, den der Client wählt. Wer den Client steuert, auch sein legitimer Nutzer, kann in die Zukunft datierte Proofs signieren und sie später von einem Rechner aus verwenden, auf dem der Schlüssel nicht liegt (§11.2, „Pre-Generation“). Eine Nonce, die der Server vorgibt, unvorhersagbar und vom Server rotiert, lässt Proofs nach der Uhr des Servers ablaufen. Außerdem spielt eine Abweichung der Uhren dann keine Rolle mehr: Der Server kann seine eigene Zeit in die Nonce kodieren, statt iat zu vertrauen (§11.1).

Die Regeln aus §8 und §9:

  • Autorisierungsserver signalisieren das mit 400 und "error": "use_dpop_nonce", Ressourcenserver mit 401 und WWW-Authenticate: DPoP error="use_dpop_nonce". Beide senden den Wert in DPoP-Nonce.
  • Hat ein Server einmal eine Nonce gesendet, darf er keine Proofs ohne Nonce akzeptieren (§11.3).
  • Ein Server kann die nächste Nonce in einer erfolgreichen Antwort mitsenden und so den zusätzlichen Roundtrip sparen; solche Antworten sollten nicht cachebar sein.
  • Browser-Clients sehen DPoP-Nonce und WWW-Authenticate nur, wenn der Server sie in Access-Control-Expose-Headers aufführt. Ohne das kann eine Single-Page-App die Nonce, die sie verwenden soll, nicht lesen.

Der Autorisierungsserver von Okta etwa beantwortet die erste DPoP-Token-Anfrage mit „Authorization server requires nonce in DPoP proof“ (Okta-Anleitung), Clients brauchen die Wiederholung also vom ersten Tag an.

Den Autorisierungscode binden: dpop_jkt

DPoP bindet Tokens am Token-Endpunkt, der Autorisierungscode wird aber schon vorher über den Browser ausgegeben. Mit dpop_jkt in der Autorisierungsanfrage (§10) kündigt der Client den Thumbprint des Schlüssels an, den er verwenden wird, und der Autorisierungsserver lehnt eine Token-Anfrage ab, deren Proof einen anderen Schlüssel verwendet. Zusammen mit PKCE schützt das den Ablauf durchgängig, sofern der Client für jede Autorisierungsanfrage einen neuen Schlüssel verwendet. FAPI 2.0 verlangt von Autorisierungsservern, die DPoP einsetzen, diese Bindung zu unterstützen, stellt ihre Nutzung den Clients aber frei (FAPI 2.0 Security Profile §5.3.2.1 und §5.3.3.1).

Wann sich DPoP lohnt

SituationDPoP?
Single-Page-App oder mobile App mit Refresh TokensJa: Refresh Tokens öffentlicher Clients sind an den Schlüssel gebunden, und genau dort hilft DPoP am meisten
FAPI 2.0 (Open Banking und Ähnliches)In der Praxis Pflicht: FAPI 2.0 verlangt sendergebundene Tokens über mTLS oder DPoP
Server-zu-Server, wenn Mutual TLS bereits im Einsatz istDie mTLS-Bindung (RFC 8705) ist die einfachere Wahl
Web-App als vertraulicher Client, die dem Browser nie Tokens zeigtOptional; die Tokens bleiben serverseitig, und ihre Refresh Tokens sind bereits an die Client-Authentifizierung gebunden
Ressourcenserver, die nicht in deiner Hand sind und nur Bearer akzeptierenEingeschränkt: Dort lässt sich das Token nur als Bearer Token verwenden, ohne den Schutz (§7.2)

Keycloak (oben getestet), Okta und Auth0 (Dokumentation) dokumentieren DPoP für ihre Token-Endpunkte, und Server veröffentlichen die akzeptierten Proof-Algorithmen als dpop_signing_alg_values_supported in ihren Metadaten (§5.1). Die Lücke liegt meist auf der Seite der Ressourcenserver, und deshalb kommt es auf den Verifizierer oben an.

Häufige Fehler

  1. Den Proof prüfen, aber nicht die Bindung. Ein Proof, der mit irgendeinem Schlüssel signiert ist, ist für sich genommen gültig; erst cnf.jkt verknüpft ihn mit dem Token.
  2. Das Token zusätzlich als Bearer akzeptieren. Dann nutzt der Angreifer einfach das Schema Bearer.
  3. htu mit dem Query-String oder einer internen URL vergleichen. htu schließt Query und Fragment aus und muss nach der Normalisierung mit der öffentlichen URL übereinstimmen.
  4. Kein Replay-Speicher und keine Nonce. Dann funktioniert ein kopierter Proof erneut, solange das Zeitfenster um iat offen ist.
  5. Ein neuer Schlüssel pro Anfrage. Der Schlüssel muss gleich bleiben, solange die daran gebundenen Tokens in Gebrauch sind.
  6. Ein symmetrischer alg oder ein privater Schlüssel in jwk. Beides verbietet §4.2; EmbeddedJWK von jose lehnt Letzteres ab.

Quellen