Skip to content
ecdsa.com
← All articles

HTTP Message Signatures (RFC 9421) for APIs and bots

TLS protects a connection; it says nothing about who sent a request once it has passed a proxy. HTTP Message Signatures sign the parts of a request that matter, so a server can check them itself. Here is the format byte by byte, an implementation verified against the RFC's test vectors and Cloudflare's library, and how crawlers and AI agents now use it to prove who they are.

Short answer. HTTP Message Signatures (RFC 9421, February 2024) let a client sign selected parts of an HTTP message, called components: derived values such as @method, @authority, @path and @query, and header fields such as content-digest. The client sends two headers: Signature-Input, listing the components and parameters (created, expires, nonce, keyid, alg, tag), and Signature, the signature over a canonical signature base. The server rebuilds the base from the request it received, looks the key up by keyid in its own configuration, and verifies. What is not covered is not protected, so a server must require the components it relies on, and the body is covered only through Content-Digest. The same mechanism, profiled as Web Bot Auth, is how crawlers and AI agents such as ChatGPT's agent now identify themselves to websites.

The format

A signed request carries both headers, keyed by a label (here sig1) so that several signatures can coexist:

The two signature headers from the api.mjs run below (first two lines of its output)
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==:

The signature is not over the raw request. Both sides build the same text, the signature base (§2.5): one line per covered component, "name": value, and a last line "@signature-params" that repeats the component list and parameters exactly as they appear in Signature-Input. Signing the parameters line means created, expires, nonce, keyid and tag cannot be changed either.

The building blocks:

ComponentValueNotes
@methodPOST§2.2.1
@authorityapi.examplehost and non-default port, lowercased (§2.2.3)
@path, @query/orders, ?dry_run=1§2.2.6, §2.2.7; @query-param signs single parameters (§2.2.8)
@target-urithe full URLbreaks if an intermediary reorders the query
a field, content-typeits value, trimmed§2.1
a dictionary member, "signature-agent";key="sig1"the member, re-serialized§2.1.2
content-digestsha-256=:…:the only way to cover the body (RFC 9530)

Parameters (§2.3): created (recommended) and expires are UNIX timestamps; nonce is a unique value for replay detection; keyid names the key; alg names the algorithm from the registry; tag names the application, so a signature made for one protocol is not accepted by another. The registered algorithms are rsa-pss-sha512, rsa-v1_5-sha256, hmac-sha256, ecdsa-p256-sha256, ecdsa-p384-sha384 and ed25519 (§3.3).

An implementation, checked against the specifications

The module below signs and verifies with Ed25519. It parses and serializes Structured Fields with the structured-headers package (2.1.1, MIT), because hand-written parsing of Signature-Input is a reliable source of interoperability bugs.

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")}:`;

Before trusting it, check it against published vectors: Appendix B.2.6 of RFC 9421 (an Ed25519 signature over the RFC's test request) and Appendix E.2.1 of the Web Bot Auth draft. Both use the RFC's test key, and only its public half is needed:

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}`);
Output (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

The first seven lines are the signature base, identical to the one printed in the RFC. Note what is not in it: the query string (@path excludes it) and the body.

The server side: what to require

RFC 9421 §3.2 lists the verification steps; the ones that decide whether a signature means anything are the application's: which components must be covered, how old a signature may be, where the key comes from, and which algorithms are allowed (§3.2.1). The key "MUST" come from something the verifier already trusts; a keyid it does not know fails (§3.2, step 5). If the algorithm is known from several places, configuration, key and alg, they must agree (step 6.5).

Here a client signs a call to an orders API, and the server requires method, authority, path, query and the body digest, plus a fresh 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();
Output (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

Every manipulation was rejected, at one of three checks. The body check fails first because the digest no longer matches; the query and method checks fail because they change a covered component, so the signature base differs. The replay fails at the nonce. Without the nonce store, the replay would have succeeded for the whole minute until expires: §7.2.2 is explicit that a signature over part of a message can be attached to another message with the same parts. Two more details from the code: the nonce is stored only after the signature verified, so unsigned junk cannot fill the store, and the nonce store must be shared across instances and kept for as long as a signature can be accepted.

Keys, algorithms and requests for signatures

Keys. RFC 9421 leaves key distribution to the application. For a fixed set of API clients, register each client's public key at onboarding and use its JWK thumbprint as keyid; for open ecosystems, publish keys at a URL the verifier can tie to an identity, as Web Bot Auth does. Rotation works as it does for JWKS: publish the new key, start signing with it, then retire the old one (see JWKS key rotation without downtime). One key per client is the rule: the Web Bot Auth draft forbids shared HMAC secrets outright (§6.4), because a secret every verifier knows proves nothing about who signed.

Algorithms. Ed25519 gives the smallest keys and signatures (64 bytes) and is deterministic; ecdsa-p256-sha256 suits keys held in a cloud KMS or HSM that lacks Ed25519; rsa-pss-sha512 is there for RSA infrastructure. The comparison in ES256 vs EdDSA for JWT applies unchanged. Whatever you choose, the verifier fixes the allowed set and rejects anything else, rather than following alg from the message (§3.2, step 6).

Asking for a signature. A server can tell a client what to sign with Accept-Signature (§5), a dictionary in the same syntax as Signature-Input, for example sig1=("@method" "@target-uri" "content-digest");created;tag="app-123". A nonce parameter there asks the client to include that exact value, which turns replay protection into a challenge and response. The Web Bot Auth draft uses the same field with 403 Forbidden when an origin wants a bot to sign (§5.3).

Responses. Signatures work in the other direction too. A server signs its response with @status and response headers, and can bind it to the request it answers by covering request components with the req parameter, for example "@authority";req (§2.4). That is how a client can tell that a webhook or an API response came from the right server and answers its request, even through intermediaries.

Compared with DPoP and mTLS. DPoP binds an OAuth token to a key and signs only the method and URL; mutual TLS authenticates the connection, which ends at the first TLS-terminating proxy. HTTP Message Signatures survive proxies and can cover the body, at the price of agreeing on which components to sign.

Web Bot Auth: signatures for crawlers and AI agents

Websites have long identified crawlers by User-Agent strings and IP ranges, both easy to fake or hard to maintain. The IETF Web Bot Auth working group standardizes a profile of RFC 9421 for this, scoped to sites that serve humans and to clients such as search crawlers, archivers and AI agents. Its protocol draft, draft-ietf-webbotauth-httpsig-protocol-00 (1 September 2026), requires:

  • at least @authority or @target-uri covered, plus created and expires (expiry recommended to be at most 24 hours), tag="web-bot-auth", and a keyid that is the key's JWK SHA-256 thumbprint;
  • a Signature-Agent header naming where the bot's keys are published, covered by the signature. In the current draft it is a dictionary, sig1="https://bot.example"; earlier drafts used a bare string, "https://bot.example";
  • a key directory: a JWK set served at /.well-known/http-message-signatures-directory on the agent's origin, with media type application/http-message-signatures-directory+json. The draft also allows a jwks_uri or a client ID metadata document, selected by a type parameter.

Our signer, cross-checked with Cloudflare's web-bot-auth library 0.2.0 (Apache-2.0) in both directions:

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}`);
Output (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

Both implementations accept each other's signatures, and Cloudflare's library requires alg, which the draft's examples always carry. The second result is the one to remember: the same headers verified on another path of the same site, because the signature covers only @authority and the agent. The draft says this outright: such a signature "verifies against any method, path, or body sent to that authority until it expires". A bot signature proves which agent sent traffic to your site in a time window, not that it made this particular request. If you need more, require @method and @path (the draft recommends that agents add them) or a nonce, and accept that a CDN may then reject requests whose path it rewrote.

In practice, October 2026

OpenAI documents that ChatGPT's agent signs its requests with RFC 9421 and Signature-Agent: "https://chatgpt.com" (OpenAI help center). Its key directory, fetched for this article:

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()}`);
}
Output, 20 October 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

The kid is the thumbprint, as the draft requires, and the key's exp was seven days after the time of the request in two fetches minutes apart: the directory computes it per response, so a verifier must refetch rather than pin the key. Note also the string form of Signature-Agent, which predates the current draft's dictionary.

That gap matters on the verifier side too. Cloudflare's Web Bot Auth documentation (updated 8 October 2026) says its verifier follows the earlier directory draft, expects the string form, and fails requests that use the dictionary form, which the IETF draft now says signers MUST send. It also states that it does not validate nonces yet. Until the two converge, a bot that wants to be verified by Cloudflare sends the string form, and a site verifying bots itself should accept both, as the draft permits for verifiers.

Outside bots, the fediverse is moving from the pre-RFC draft-cavage-http-signatures-12 to RFC 9421: per Mastodon's FEDERATION.md, 4.5 and later accept RFC 9421 signatures with rsa-v1_5-sha256, and 4.7 adds ed25519 and uses RFC 9421 for outgoing requests when a server rejects the old format.

Common mistakes

  1. Trusting a signature without checking what it covers. A valid signature over @authority alone says nothing about the path or the body. Make the required components a server-side list.
  2. Covering a body without Content-Digest. There is no body component; the digest header is it, and the server must compare it with the bytes it received.
  3. Rebuilding @authority or @target-uri from an internal URL behind a proxy. Use the public host the client signed; see the RFC's Appendix B.3 on TLS-terminating proxies.
  4. Taking the key from the message. keyid is a lookup key into your configuration or a directory you chose to trust, never a URL to fetch blindly.
  5. No replay strategy. Short expires, a nonce store for state-changing requests, or both.
  6. Hand-serializing Structured Fields. Parameter order, quoting and byte sequences must be exact; a library such as structured-headers removes a class of bugs.

Sources