Zum Inhalt springen
ecdsa.com
← Alle Artikel

HTTP Message Signatures (RFC 9421) für APIs und Bots

TLS schützt eine Verbindung, sagt aber nichts mehr darüber, wer eine Anfrage gesendet hat, sobald sie einen Proxy passiert hat. HTTP Message Signatures signieren die Teile einer Anfrage, auf die es ankommt, sodass ein Server sie selbst prüfen kann. Hier findest du das Format Byte für Byte, eine Implementierung, die gegen die Testvektoren des RFC und die Bibliothek von Cloudflare geprüft ist, und wie Crawler und KI-Agenten damit inzwischen nachweisen, wer sie sind.

Kurz gesagt. HTTP Message Signatures (RFC 9421, Februar 2024) erlauben es einem Client, ausgewählte Teile einer HTTP-Nachricht zu signieren, die sogenannten Komponenten: abgeleitete Werte wie @method, @authority, @path und @query sowie Header-Felder wie content-digest. Der Client sendet zwei Header: Signature-Input mit der Liste der Komponenten und Parameter (created, expires, nonce, keyid, alg, tag) und Signature, die Signatur über eine kanonische Signaturbasis. Der Server baut die Basis aus der empfangenen Anfrage nach, sucht den Schlüssel über keyid in seiner eigenen Konfiguration heraus und prüft die Signatur. Was nicht abgedeckt ist, ist nicht geschützt: Ein Server muss deshalb die Komponenten verlangen, auf die er sich verlässt, und der Body ist nur über Content-Digest abgedeckt. Mit demselben Mechanismus, festgelegt als Profil Web Bot Auth, weisen sich Crawler und KI-Agenten wie der Agent von ChatGPT inzwischen gegenüber Websites aus.

Das Format

Eine signierte Anfrage trägt beide Header, jeweils unter einem Label (hier sig1), damit mehrere Signaturen nebeneinander bestehen können:

Die beiden Signatur-Header aus dem Lauf von api.mjs weiter unten (die ersten zwei Zeilen seiner Ausgabe)
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-type" "content-digest");created=1792500460;expires=1792500520;nonce="efJP6sr_BCwi5qPLrNO3Ww";keyid="x34Se3XYBjrAMq38kQtGsGbCaxWCSVcdWIXk6UxriM8";alg="ed25519";tag="orders-api"
Signature: sig1=:U+ydGQT6crfYbivRojgFzn3ZnAvMhwMeTq9b9LxbFEm460FXzxjlcUDsLBvO4Sfvz9HsgcJ8phf1V+CrbjL0BA==:

Signiert wird nicht die rohe Anfrage. Beide Seiten bauen denselben Text auf, die Signaturbasis (§2.5): eine Zeile pro abgedeckter Komponente, "name": value, und als letzte Zeile "@signature-params", die die Liste der Komponenten und die Parameter genau so wiederholt, wie sie in Signature-Input stehen. Weil die Parameterzeile mitsigniert wird, lassen sich auch created, expires, nonce, keyid und tag nicht ändern.

Die Bausteine:

KomponenteWertHinweise
@methodPOST§2.2.1
@authorityapi.exampleHost und Nicht-Standard-Port, in Kleinbuchstaben (§2.2.3)
@path, @query/orders, ?dry_run=1§2.2.6, §2.2.7; @query-param signiert einzelne Parameter (§2.2.8)
@target-uridie vollständige URLscheitert, wenn ein Zwischensystem die Reihenfolge im Query-String ändert
ein Feld, content-typesein Wert, ohne Whitespace am Anfang und Ende§2.1
ein Dictionary-Eintrag, "signature-agent";key="sig1"der Eintrag, neu serialisiert§2.1.2
content-digestsha-256=:…:der einzige Weg, den Body abzudecken (RFC 9530)

Parameter (§2.3): created (empfohlen) und expires sind UNIX-Zeitstempel; nonce ist ein eindeutiger Wert, an dem sich Replays erkennen lassen; keyid benennt den Schlüssel; alg benennt den Algorithmus aus der Registry; tag benennt die Anwendung, sodass eine für ein Protokoll erstellte Signatur von einem anderen nicht akzeptiert wird. Die registrierten Algorithmen sind rsa-pss-sha512, rsa-v1_5-sha256, hmac-sha256, ecdsa-p256-sha256, ecdsa-p384-sha384 und ed25519 (§3.3).

Eine Implementierung, geprüft gegen die Spezifikationen

Das folgende Modul signiert und prüft mit Ed25519. Structured Fields parst und serialisiert es mit dem Paket structured-headers (2.1.1, MIT), denn selbst geschriebenes Parsen von Signature-Input ist eine verlässliche Quelle für Interoperabilitätsfehler.

http-sig.mjs (Node.js 24.22, structured-headers 2.1.1)
import { createHash, sign as edSign, verify as edVerify } from "node:crypto";
import { parseDictionary, serializeInnerList, serializeItem } from "structured-headers";

/** Value of one covered component (RFC 9421 §2.1, §2.2). req: { method, url, headers: Map of lowercased names }. */
function componentValue(req, name, params) {
  const url = new URL(req.url);
  switch (name) {
    case "@method": return req.method.toUpperCase();
    case "@authority": return url.host.toLowerCase(); // default ports are already dropped by URL
    case "@path": return url.pathname;
    case "@query": return url.search || "?";
    case "@target-uri": return url.href;
    case "@scheme": return url.protocol.slice(0, -1);
  }
  const raw = req.headers.get(name);
  if (raw === undefined) throw new Error(`covered field "${name}" is missing`);
  if (params.has("key")) {
    // A member of a Dictionary Structured Field (§2.1.2), re-serialized.
    const member = parseDictionary(raw).get(params.get("key"));
    if (!member) throw new Error(`dictionary member ${params.get("key")} of "${name}" is missing`);
    return serializeItem(member);
  }
  return raw.trim();
}

/** The signature base (§2.5): one line per component, then @signature-params. */
export function signatureBase(req, components, params) {
  const lines = components.map(([name, p]) => `${serializeItem([name, p])}: ${componentValue(req, name, p)}`);
  const signatureParams = serializeInnerList([components.map(([n, p]) => [n, p]), params]);
  return { base: [...lines, `"@signature-params": ${signatureParams}`].join("\n"), signatureParams };
}

/** Signs with Ed25519 and returns the two header values for one label (§3.1, §4). */
export function signRequest(req, { label, components, params, privateKey }) {
  const c = components.map((x) => (Array.isArray(x) ? [x[0], new Map(Object.entries(x[1]))] : [x, new Map()]));
  const { base, signatureParams } = signatureBase(req, c, new Map(Object.entries(params)));
  const sig = edSign(null, Buffer.from(base), privateKey).toString("base64");
  return { "signature-input": `${label}=${signatureParams}`, signature: `${label}=:${sig}:` };
}

/** Verifies one labelled signature (§3.2) and enforces the caller's policy. */
export function verifyRequest(req, { label, resolveKey, required = [], maxAge = 300, now = Math.floor(Date.now() / 1000), seenNonces }) {
  const input = parseDictionary(req.headers.get("signature-input") ?? "").get(label);
  const signature = parseDictionary(req.headers.get("signature") ?? "").get(label);
  if (!input || !signature) throw new Error(`no signature labelled ${label}`);
  const [items, params] = input;
  const components = items.map(([name, p]) => [name, p]);
  const covered = components.map(([n, p]) => (p.has("key") ? `${n};key=${p.get("key")}` : n));
  for (const name of required) if (!covered.includes(name)) throw new Error(`"${name}" is not covered`);
  const created = params.get("created");
  const expires = params.get("expires");
  if (typeof created !== "number" || created > now + 5) throw new Error("created missing or in the future");
  if (now - created > maxAge || (typeof expires === "number" && now >= expires)) throw new Error("signature expired");
  if (params.has("alg") && params.get("alg") !== "ed25519") throw new Error(`alg ${params.get("alg")} not accepted`);
  const key = resolveKey(params.get("keyid"), params.get("tag")); // never from the message itself
  const { base } = signatureBase(req, components, params);
  if (!edVerify(null, Buffer.from(base), key, Buffer.from(signature[0]))) throw new Error("signature does not verify");
  // Record the nonce only for a valid signature, so forged requests cannot fill the cache.
  const nonce = params.get("nonce");
  if (seenNonces) {
    if (nonce === undefined) throw new Error("nonce required");
    if (seenNonces.has(nonce)) throw new Error("nonce already used");
    seenNonces.add(nonce);
  }
  return { keyid: params.get("keyid"), covered, created, tag: params.get("tag") };
}

/** Content-Digest (RFC 9530) so that a signature can cover the body. */
export const contentDigest = (body) => `sha-256=:${createHash("sha256").update(body).digest("base64")}:`;

Bevor du dem Modul vertraust, prüfe es gegen veröffentlichte Testvektoren: Anhang B.2.6 von RFC 9421 (eine Ed25519-Signatur über die Testanfrage des RFC) und Anhang E.2.1 des Web-Bot-Auth-Entwurfs. Beide verwenden den Testschlüssel des RFC, und gebraucht wird nur seine öffentliche Hälfte:

vectors.mjs
// Published test vectors: RFC 9421 Appendix B.2.6 and the Web Bot Auth draft's Appendix E.2.1.
// Both are verified with the RFC's Ed25519 test public key (Appendix B.1.4) and nothing else.
import { createPublicKey } from "node:crypto";
import { calculateJwkThumbprint } from "jose";
import { signatureBase, verifyRequest } from "./http-sig.mjs";

const headersOf = (obj) => new Map(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]));
const rfcKey = createPublicKey("-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=\n-----END PUBLIC KEY-----\n");

const b26 = {
  method: "POST",
  url: "https://example.com/foo?param=Value&Pet=dog",
  headers: headersOf({
    Date: "Tue, 20 Apr 2021 02:07:55 GMT",
    "Content-Type": "application/json",
    "Content-Length": "18",
    "Signature-Input": 'sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"',
    Signature: "sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:",
  }),
};
const covered = ["date", "@method", "@path", "@authority", "content-type", "content-length"].map((n) => [n, new Map()]);
console.log(signatureBase(b26, covered, new Map([["created", 1618884473], ["keyid", "test-key-ed25519"]])).base);
const r1 = verifyRequest(b26, { label: "sig-b26", resolveKey: () => rfcKey, now: 1618884480 });
console.log(`RFC 9421 B.2.6: verifies, keyid ${r1.keyid}`);

const e21 = {
  method: "GET",
  url: "https://example.com/foo?param=Value&Pet=dog",
  headers: headersOf({
    "Signature-Agent": 'agent2="https://signature-agent.test"',
    "Signature-Input": 'sig2=("@authority" "signature-agent";key="agent2");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=4889289600;nonce="n9p433xm+NJ3ph3upfBIGmsuwHw387YV7Q/F+6BSpGCVjYCqQw6rznNA8PVVLySrAWsv0hQtFioQb6E1YsauiA==";tag="web-bot-auth"',
    Signature: "sig2=:RdNFx5Bj6au3YgAMQL/RzmUlZE8QZLIaXGRpw985hWnwPfMxT228NMk6ehRS1PSl4e8PhbNZACSanGdhEwYCCg==:",
  }),
};
const r2 = verifyRequest(e21, { label: "sig2", resolveKey: () => rfcKey, maxAge: Infinity, now: 1735689700 });
const thumbprint = await calculateJwkThumbprint(rfcKey.export({ format: "jwk" }));
console.log(`Web Bot Auth E.2.1: verifies, covers ${r2.covered.join(", ")}, keyid is the key's JWK thumbprint: ${r2.keyid === thumbprint}`);
Ausgabe (node:24.22.0-bookworm-slim)
"date": Tue, 20 Apr 2021 02:07:55 GMT
"@method": POST
"@path": /foo
"@authority": example.com
"content-type": application/json
"content-length": 18
"@signature-params": ("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"
RFC 9421 B.2.6: verifies, keyid test-key-ed25519
Web Bot Auth E.2.1: verifies, covers @authority, signature-agent;key=agent2, keyid is the key's JWK thumbprint: true

Die ersten sieben Zeilen sind die Signaturbasis, identisch mit der im RFC abgedruckten. Beachte, was darin fehlt: der Query-String (@path schließt ihn aus) und der Body.

Die Serverseite: was du verlangen solltest

RFC 9421 §3.2 zählt die Schritte der Prüfung auf; die Schritte, die entscheiden, ob eine Signatur überhaupt etwas bedeutet, liegen bei der Anwendung: welche Komponenten abgedeckt sein müssen, wie alt eine Signatur sein darf, woher der Schlüssel kommt und welche Algorithmen zulässig sind (§3.2.1). Der Schlüssel muss („MUST“) aus einer Quelle stammen, der der Verifizierer bereits vertraut; mit einer keyid, die er nicht kennt, scheitert die Prüfung (§3.2, Schritt 5). Ist der Algorithmus aus mehreren Quellen bekannt, aus Konfiguration, Schlüssel und alg, müssen sie übereinstimmen (Schritt 6.5).

Hier signiert ein Client einen Aufruf an eine Bestell-API, und der Server verlangt Methode, Authority, Pfad, Query und den Digest des Bodys, dazu eine frische Nonce:

api.mjs
// A client signs an API call; the server requires method, authority, path, query and body to be covered.
import { generateKeyPairSync, randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { calculateJwkThumbprint } from "jose";
import { contentDigest, signRequest, verifyRequest } from "./http-sig.mjs";

const client = generateKeyPairSync("ed25519");
const keyid = await calculateJwkThumbprint(client.publicKey.export({ format: "jwk" }));
const registeredKeys = new Map([[keyid, client.publicKey]]); // the server's list of client keys
const seenNonces = new Set(); // shared store in production, entries kept until the signature expires

const server = createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  const request = { method: req.method, url: `https://api.example${req.url}`, headers: new Map(Object.entries(req.headers)) };
  try {
    if (req.headers["content-digest"] !== contentDigest(body)) throw new Error("content-digest does not match the body");
    const ok = verifyRequest(request, {
      label: "sig1",
      resolveKey: (id) => {
        if (!registeredKeys.has(id)) throw new Error(`unknown keyid ${id}`);
        return registeredKeys.get(id);
      },
      required: ["@method", "@authority", "@path", "@query", "content-digest"],
      seenNonces,
    });
    res.writeHead(200).end(`ok, client ${ok.keyid.slice(0, 8)}...`);
  } catch (e) {
    res.writeHead(401).end(e.message);
  }
}).listen(8443);

// Client side.
const body = JSON.stringify({ sku: "A-7", qty: 2 });
const now = Math.floor(Date.now() / 1000);
const headers = { "Content-Type": "application/json", "Content-Digest": contentDigest(body) };
const signed = signRequest(
  { method: "POST", url: "https://api.example/orders?dry_run=1", headers: new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) },
  {
    label: "sig1",
    components: ["@method", "@authority", "@path", "@query", "content-type", "content-digest"],
    params: { created: now, expires: now + 60, nonce: randomBytes(16).toString("base64url"), keyid, alg: "ed25519", tag: "orders-api" },
    privateKey: client.privateKey,
  },
);
console.log(`Signature-Input: ${signed["signature-input"]}\nSignature: ${signed.signature}`);

const send = async (label, { path = "/orders?dry_run=1", method = "POST", payload = body } = {}) => {
  const res = await fetch(`http://127.0.0.1:8443${path}`, { method, body: payload, headers: { ...headers, ...signed } });
  console.log(`${label.padEnd(22)} ${res.status} ${await res.text()}`);
};
await send("signed request");
await send("same request again");
await send("body changed", { payload: JSON.stringify({ sku: "A-7", qty: 200 }) });
await send("query changed", { path: "/orders?dry_run=0" });
await send("method changed", { method: "PUT" });
server.close();
Ausgabe (node:24.22.0-bookworm-slim)
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-type" "content-digest");created=1792500460;expires=1792500520;nonce="efJP6sr_BCwi5qPLrNO3Ww";keyid="x34Se3XYBjrAMq38kQtGsGbCaxWCSVcdWIXk6UxriM8";alg="ed25519";tag="orders-api"
Signature: sig1=:U+ydGQT6crfYbivRojgFzn3ZnAvMhwMeTq9b9LxbFEm460FXzxjlcUDsLBvO4Sfvz9HsgcJ8phf1V+CrbjL0BA==:
signed request         200 ok, client x34Se3XY...
same request again     401 nonce already used
body changed           401 content-digest does not match the body
query changed          401 signature does not verify
method changed         401 signature does not verify

Jede Manipulation wurde abgewiesen, und zwar an einer von drei Prüfungen. Der Test mit geändertem Body scheitert schon an der ersten Prüfung, weil der Digest nicht mehr passt; die Tests mit geänderter Query und geänderter Methode scheitern, weil diese Änderungen eine abgedeckte Komponente betreffen und sich damit die Signaturbasis unterscheidet. Das Replay scheitert an der Nonce. Ohne den Nonce-Speicher wäre das Replay die ganze Minute bis expires durchgegangen: §7.2.2 stellt ausdrücklich klar, dass sich eine Signatur über einen Teil einer Nachricht an eine andere Nachricht mit denselben Teilen anhängen lässt. Zwei weitere Details aus dem Code: Die Nonce wird erst gespeichert, nachdem die Signatur erfolgreich geprüft wurde, sodass unsignierter Datenmüll den Speicher nicht füllen kann, und der Nonce-Speicher muss über alle Instanzen hinweg geteilt und so lange aufbewahrt werden, wie eine Signatur akzeptiert werden kann.

Schlüssel, Algorithmen und das Anfordern von Signaturen

Schlüssel. RFC 9421 überlässt die Verteilung der Schlüssel der Anwendung. Bei einem festen Kreis von API-Clients registrierst du beim Onboarding den öffentlichen Schlüssel jedes Clients und verwendest seinen JWK-Thumbprint als keyid; in offenen Ökosystemen veröffentlichst du die Schlüssel unter einer URL, die der Verifizierer einer Identität zuordnen kann, wie es Web Bot Auth tut. Die Rotation funktioniert wie bei JWKS: Veröffentliche den neuen Schlüssel, signiere ab dann mit ihm und ziehe danach den alten zurück (siehe JWKS-Schlüsselrotation ohne Ausfallzeit). Ein Schlüssel pro Client ist die Regel: Der Web-Bot-Auth-Entwurf verbietet gemeinsame Geheimnisse für HMAC ausdrücklich (§6.4), denn ein Geheimnis, das jeder Verifizierer kennt, beweist nichts darüber, wer signiert hat.

Algorithmen. Ed25519 liefert die kleinsten Schlüssel und Signaturen (64 Byte) und ist deterministisch; ecdsa-p256-sha256 passt zu Schlüsseln in einem Cloud-KMS oder HSM ohne Ed25519; rsa-pss-sha512 ist für RSA-Infrastruktur da. Der Vergleich in ES256 vs. EdDSA für JWTs gilt unverändert. Was du auch wählst: Der Verifizierer legt fest, welche Algorithmen zulässig sind, und lehnt alles andere ab, statt alg aus der Nachricht zu folgen (§3.2, Schritt 6).

Eine Signatur anfordern. Mit Accept-Signature (§5) kann ein Server einem Client mitteilen, was er signieren soll: ein Dictionary in derselben Syntax wie Signature-Input, zum Beispiel sig1=("@method" "@target-uri" "content-digest");created;tag="app-123". Ein Parameter nonce darin fordert den Client auf, genau diesen Wert aufzunehmen; damit wird aus dem Replay-Schutz ein Challenge-Response-Verfahren. Der Web-Bot-Auth-Entwurf verwendet dasselbe Feld zusammen mit 403 Forbidden, wenn ein Origin will, dass ein Bot signiert (§5.3).

Antworten. Signaturen funktionieren auch in die andere Richtung. Ein Server signiert seine Antwort mit @status und Antwort-Headern und kann sie an die Anfrage binden, auf die sie antwortet, indem er mit dem Parameter req Komponenten der Anfrage abdeckt, zum Beispiel "@authority";req (§2.4). So erkennt ein Client, dass ein Webhook oder eine API-Antwort vom richtigen Server stammt und zu seiner Anfrage gehört, auch über Zwischensysteme hinweg.

Im Vergleich mit DPoP und mTLS. DPoP bindet ein OAuth-Token an einen Schlüssel und signiert nur Methode und URL; Mutual TLS authentifiziert die Verbindung, und die endet am ersten TLS-terminierenden Proxy. HTTP Message Signatures überstehen Proxys und können den Body abdecken; der Preis dafür ist, dass sich beide Seiten darauf einigen müssen, welche Komponenten signiert werden.

Web Bot Auth: Signaturen für Crawler und KI-Agenten

Websites erkennen Crawler seit Langem an User-Agent-Strings und IP-Bereichen, die sich leicht fälschen lassen oder mühsam zu pflegen sind. Die Web-Bot-Auth-Arbeitsgruppe der IETF standardisiert dafür ein Profil von RFC 9421, beschränkt auf Websites, die sich an Menschen richten, und auf Clients wie Suchmaschinen-Crawler, Archivierungsdienste und KI-Agenten. Deren Protokollentwurf, draft-ietf-webbotauth-httpsig-protocol-00 (1. September 2026), verlangt:

  • mindestens @authority oder @target-uri als abgedeckte Komponente, dazu created und expires (empfohlen ist ein Ablauf nach höchstens 24 Stunden), tag="web-bot-auth" und eine keyid, die der JWK-SHA-256-Thumbprint des Schlüssels ist;
  • einen Header Signature-Agent, der nennt, wo die Schlüssel des Bots veröffentlicht sind, und der von der Signatur abgedeckt ist. Im aktuellen Entwurf ist er ein Dictionary, sig1="https://bot.example"; frühere Entwürfe verwendeten einen einfachen String, "https://bot.example";
  • ein Schlüsselverzeichnis: ein JWK-Set, ausgeliefert unter /.well-known/http-message-signatures-directory auf dem Origin des Agenten, mit dem Medientyp application/http-message-signatures-directory+json. Der Entwurf erlaubt auch eine jwks_uri oder ein Client-ID-Metadatendokument, ausgewählt über einen Parameter type.

Unser Signierer, in beide Richtungen abgeglichen mit der Bibliothek web-bot-auth 0.2.0 (Apache-2.0) von Cloudflare:

bot.mjs (web-bot-auth 0.2.0)
// Web Bot Auth: our signer and verifier against Cloudflare's web-bot-auth 0.2.0, in both directions.
import { generateKeyPairSync, randomBytes } from "node:crypto";
import { calculateJwkThumbprint } from "jose";
import { sign as wbaSign, verify as wbaVerify } from "web-bot-auth";
import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto";
import { signRequest, verifyRequest } from "./http-sig.mjs";

const bot = generateKeyPairSync("ed25519");
const publicJwk = bot.publicKey.export({ format: "jwk" });
const keyid = await calculateJwkThumbprint(publicJwk); // the draft requires the JWK thumbprint as keyid
const agent = 'sig1="https://bot.example"'; // dictionary form, as draft-ietf-webbotauth-httpsig-protocol-00 requires
const now = Math.floor(Date.now() / 1000);

// Our signer: @authority and the Signature-Agent member, tag web-bot-auth, five minutes of validity.
const ours = signRequest(
  { method: "GET", url: "https://shop.example/products", headers: new Map([["signature-agent", agent]]) },
  {
    label: "sig1",
    components: ["@authority", ["signature-agent", { key: "sig1" }]],
    params: { created: now, expires: now + 300, keyid, alg: "ed25519", nonce: randomBytes(64).toString("base64"), tag: "web-bot-auth" },
    privateKey: bot.privateKey,
  },
);
console.log(`Signature-Agent: ${agent}\nSignature-Input: ${ours["signature-input"]}`);

// Cloudflare's library verifies it. The resolver maps keyid to a key the site already trusts.
const verifier = await verifierFromJWK({ ...publicJwk, alg: "EdDSA" });
const resolver = (candidate) => {
  if (candidate.keyid !== verifier.keyid) throw new Error("unknown key");
  return verifier;
};
const asRequest = (url) => new Request(url, { headers: { "Signature-Agent": agent, "Signature-Input": ours["signature-input"], Signature: ours.signature } });
for (const [label, url] of [
  ["web-bot-auth verifies ours", "https://shop.example/products"],
  ["same headers, another path", "https://shop.example/admin"],
  ["same headers, another host", "https://other.example/products"],
]) {
  try {
    const r = await wbaVerify(asRequest(url), { resolver });
    console.log(`${label.padEnd(28)} ok, agent ${r.signatureAgent.uri}, discovery ${r.signatureAgent.type}`);
  } catch (e) {
    console.log(`${label.padEnd(28)} ${e.name}: ${e.message}`);
  }
}

// The library signs, our verifier checks.
const theirs = await wbaSign(new Request("https://shop.example/products", { headers: { "Signature-Agent": agent } }), {
  signer: await signerFromJWK({ ...bot.privateKey.export({ format: "jwk" }), alg: "EdDSA" }),
  expires: new Date(Date.now() + 300_000),
});
const checked = verifyRequest(
  {
    method: "GET",
    url: "https://shop.example/products",
    headers: new Map([["signature-agent", agent], ["signature-input", theirs.signatureInput], ["signature", theirs.signature]]),
  },
  { label: "sig1", resolveKey: () => bot.publicKey, required: ["@authority", "signature-agent;key=sig1"] },
);
console.log(`library's Signature-Input: ${theirs.signatureInput}`);
console.log(`our verifier: ok, covers ${checked.covered.join(", ")}, tag ${checked.tag}`);
Ausgabe (node:24.22.0-bookworm-slim)
Signature-Agent: sig1="https://bot.example"
Signature-Input: sig1=("@authority" "signature-agent";key="sig1");created=1792500467;expires=1792500767;keyid="47reD7FOwk8HOy0YI79OYtwg88h_Ls1I9z6vsGKdp-E";alg="ed25519";nonce="tHPV4puMjEd7xZrTuiURmxWxcfaGTSo4xaMAkpFltfVwupzv4lQwxQNfPRyTwwPODLd3sdVbkueLt9bii4TOOw==";tag="web-bot-auth"
web-bot-auth verifies ours   ok, agent https://bot.example, discovery directory
same headers, another path   ok, agent https://bot.example, discovery directory
same headers, another host   SignatureError: Signature verification failed
library's Signature-Input: sig1=("@authority" "signature-agent";key="sig1");created=1792500467;keyid="47reD7FOwk8HOy0YI79OYtwg88h_Ls1I9z6vsGKdp-E";alg="ed25519";expires=1792500767;tag="web-bot-auth"
our verifier: ok, covers @authority, signature-agent;key=sig1, tag web-bot-auth

Beide Implementierungen akzeptieren die Signaturen der jeweils anderen, und die Bibliothek von Cloudflare verlangt alg, das die Beispiele des Entwurfs immer enthalten. Merken solltest du dir das zweite Ergebnis: Dieselben Header wurden auf einem anderen Pfad derselben Website als gültig erkannt, weil die Signatur nur @authority und den Agenten abdeckt. Der Entwurf sagt das ausdrücklich: Eine solche Signatur ist „für jede Methode, jeden Pfad und jeden Body gültig, die an diese Authority gesendet werden, bis sie abläuft“. Eine Bot-Signatur beweist, welcher Agent in einem Zeitfenster Traffic an deine Website gesendet hat, nicht, dass er genau diese Anfrage gestellt hat. Brauchst du mehr, verlange @method und @path (der Entwurf empfiehlt den Agenten, sie aufzunehmen) oder eine Nonce, und nimm in Kauf, dass ein CDN dann Anfragen ablehnen kann, deren Pfad es umgeschrieben hat.

In der Praxis, Oktober 2026

OpenAI dokumentiert, dass der Agent von ChatGPT seine Anfragen nach RFC 9421 und mit Signature-Agent: "https://chatgpt.com" signiert (Hilfe-Center von OpenAI). Sein Schlüsselverzeichnis, für diesen Artikel abgerufen:

thumbprint.mjs
// Fetches ChatGPT's Web Bot Auth key directory and checks that each kid is the key's JWK SHA-256 thumbprint.
import { calculateJwkThumbprint } from "jose";

const url = "https://chatgpt.com/.well-known/http-message-signatures-directory";
const res = await fetch(url, { headers: { accept: "application/http-message-signatures-directory+json" } });
console.log(`${res.status} ${res.headers.get("content-type")}`);
for (const key of (await res.json()).keys) {
  const thumbprint = await calculateJwkThumbprint({ kty: key.kty, crv: key.crv, x: key.x });
  console.log(`${key.kty} ${key.crv}, kid ${key.kid}`);
  console.log(`kid is the JWK thumbprint: ${thumbprint === key.kid}, nbf ${new Date(key.nbf * 1000).toISOString()}, exp ${new Date(key.exp * 1000).toISOString()}`);
}
Ausgabe, 20. Oktober 2026
200 application/http-message-signatures-directory+json
OKP Ed25519, kid ArwjqcCqtA5oxEWkzXGC-To_0A9whHmajCHtfe8I9aY
kid is the JWK thumbprint: true, nbf 2025-01-01T00:00:00.000Z, exp 2026-10-27T12:48:27.000Z

Der Wert von kid ist der Thumbprint, wie es der Entwurf verlangt, und der Wert exp des Schlüssels lag bei zwei Abrufen im Abstand weniger Minuten jeweils sieben Tage nach dem Zeitpunkt der Anfrage: Das Verzeichnis berechnet ihn pro Antwort, ein Verifizierer muss den Schlüssel also neu abrufen, statt ihn fest zu hinterlegen. Beachte auch die String-Form von Signature-Agent, die älter ist als die Dictionary-Form des aktuellen Entwurfs.

Dieser Unterschied zählt auch auf der Seite des Verifizierers. Laut der Web-Bot-Auth-Dokumentation von Cloudflare (aktualisiert am 8. Oktober 2026) folgt Cloudflares Verifizierer dem früheren Verzeichnisentwurf, erwartet die String-Form und lässt Anfragen in der Dictionary-Form scheitern, obwohl Signierer laut dem IETF-Entwurf inzwischen genau diese Form senden müssen („MUST“). Außerdem heißt es dort, dass der Verifizierer Nonces noch nicht prüft. Bis beide zusammenfinden, sendet ein Bot, der von Cloudflare geprüft werden will, die String-Form, und eine Website, die Bots selbst prüft, sollte beide Formen akzeptieren, wie es der Entwurf Verifizierern erlaubt.

Abseits der Bots wechselt das Fediverse vom Vor-RFC-Entwurf draft-cavage-http-signatures-12 zu RFC 9421: Laut FEDERATION.md von Mastodon akzeptieren die Versionen ab 4.5 Signaturen nach RFC 9421 mit rsa-v1_5-sha256, und 4.7 ergänzt ed25519 und verwendet RFC 9421 für ausgehende Anfragen, wenn ein Server das alte Format ablehnt.

Häufige Fehler

  1. Einer Signatur vertrauen, ohne zu prüfen, was sie abdeckt. Eine gültige Signatur nur über @authority sagt nichts über den Pfad oder den Body. Lege die geforderten Komponenten serverseitig in einer Liste fest.
  2. Einen Body ohne Content-Digest abdecken. Es gibt keine Komponente für den Body; diese Rolle übernimmt der Digest-Header, und der Server muss ihn mit den empfangenen Bytes vergleichen.
  3. @authority oder @target-uri aus einer internen URL nachbauen, wenn der Server hinter einem Proxy steht. Verwende den öffentlichen Host, den der Client signiert hat; siehe Anhang B.3 des RFC zu TLS-terminierenden Proxys.
  4. Den Schlüssel aus der Nachricht nehmen. keyid ist ein Suchschlüssel für deine Konfiguration oder für ein Verzeichnis, dem du bewusst vertraust, niemals eine URL, die du blind abrufst.
  5. Keine Strategie gegen Replays. Ein kurzes expires, ein Nonce-Speicher für zustandsändernde Anfragen oder beides.
  6. Structured Fields von Hand serialisieren. Reihenfolge der Parameter, Anführungszeichen und Bytefolgen müssen exakt stimmen; eine Bibliothek wie structured-headers beseitigt eine ganze Klasse von Fehlern.

Quellen