RFC 9421 HTTP message signatures for AI agents
RFC 9421 lets a client sign chosen parts of an HTTP request, so a server can check which key sent it. On its own it does not bind the body and does not stop replay. For an AI agent you add a Content-Digest, a nonce, a short expiry, and a verifier that refuses signatures covering too little.
A bearer API key proves nothing about who sent the request
RFC 6750 defines the property that makes a stolen header dangerous. A bearer token is one "with the property that any party in possession of the token (a "bearer") can use the token in any way that any other party in possession of it can."
An LLM provider key is that kind of credential. Anthropic's API overview, read on 3 October 2026, lists Authorization: Bearer <token> where the token is "your API key or a short-lived access token", with x-api-key as a legacy fallback. If an agent's environment leaks, the provider cannot tell the thief from the agent.
A signed request changes what the server checks. The agent keeps a private key. Each request carries a signature that only that key could produce over that request. Copying the headers off the wire does not give the thief a second valid request.
RFC 9421 is the standard way to do this over HTTP. Its abstract says it describes "a mechanism for creating, encoding, and verifying digital signatures or message authentication codes over components of an HTTP message." The rest of this entry is about the parts the RFC leaves to the application, because those are where agent deployments go wrong.
What the RFC signs: components you choose, plus parameters
A signature covers a list of named components. Some are headers, such as content-type. Some are derived values, written with an @, such as @method, @path and @authority. The signer builds a text block called the signature base, one line per component, and ends it with an @signature-params line that repeats the component list and the parameters. It signs those bytes.
The RFC's own Ed25519 example, from appendix B.2.6, shows the shape. Its line wrapping follows RFC 8792.
"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"
The result travels in two headers. Signature-Input repeats the component list and parameters. Signature carries the signature itself:
Signature-Input: sig-b26=("date" "@method" "@path" "@authority" \
"content-type" "content-length");created=1618884473\
;keyid="test-key-ed25519"
Section 2.3 defines the parameters a signer can attach. Four matter for agents:
| Parameter | RFC 9421 wording | Use for an agent |
|---|---|---|
created | "Creation time as a UNIX timestamp value of type Integer" | When the agent signed |
expires | "Expiration time as a UNIX timestamp value of type Integer" | A short life for a captured request |
nonce | "A random unique value generated for this signature as a String value" | Detecting a repeat |
keyid | "The identifier for the key material as a String value" | Which key to verify with |
Verification (section 3.2) rebuilds the signature base from the received message and the declared components, then checks the signature against the key. The list of covered components comes from the sender's Signature-Input header, so the verifier has to decide whether that list is enough. That point comes back below.
The RFC does not sign the body, so add a digest
Look at the example again. It covers content-type and content-length, but nothing in it commits to the JSON that follows. For an agent that matters: the body holds the model name, the token limit and the prompt, which is where the cost is decided.
Section 7.2.8 is direct about it. The specification "does not provide coverage for the content of an HTTP message under the signature, in either a request or a response." It points to the Content-Digest field from RFC 9530, which "can be used in requests and responses to communicate digests that are calculated using a hashing algorithm applied to the actual message content." Once the field exists, you list content-digest as a covered component like any header.
The digest covers exact bytes. RFC 9421 section 7.2.8 and RFC 9530 use the same JSON object and give different digests:
{"hello": "world"} sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
{"hello": "world"}\n sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=:
RFC 9421 shows the first value. RFC 9530 shows the second next to Content-Length: 19, one byte more than the 18 in the RFC 9421 example. Computing SHA-256 over both byte strings gives exactly those two values: the second input ends in a newline. A client that re-serializes JSON after hashing, or a proxy that reflows it, produces a mismatch even though the data means the same thing. Hash the bytes you send.
A digest alone is not protection either. RFC 9530 section 6.1 warns: "In the absence of additional security mechanisms, an on-path malicious actor can either remove a digest value entirely or substitute it with a new digest value computed over manipulated representation data or content." The signature over content-digest is the additional mechanism. Remove the digest from the covered list and the body is unprotected again.
Replay protection needs a nonce and a verifier that remembers
Section 7.2.2 describes the failure plainly. "The most extreme form of this would be a signature over no message components. If such a signature were intercepted, it could be replayed at will by an attacker, attached to any HTTP message." Even with good coverage, "a given signature could be applied to two similar HTTP messages."
The RFC's remedies are layered. Cover enough of the message to tell it from others. Add nonce "to allow the verifier to detect replay of the signature itself if a nonce value is repeated". Add created and expires, "limiting the utility of a captured signature value."
Two consequences for an implementer:
- The nonce only works if the verifier stores it. A nonce check against an in-memory set forgets everything when the process restarts, and the window in which that matters is the signature's remaining life.
- The store should keep a nonce until after
expires, with a margin for clock differences, so a captured request cannot pass in the last instant of its window.
A short expires makes the store small. A signature that lives 30 seconds needs 30 seconds of nonce memory, not a day's.
The verifier, not the signer, decides what must be covered
Section 1.4 puts the burden on whoever deploys it. "An application or profile of this specification MUST specify, at a minimum" the set of components and parameters "that are expected and required to be included in the covered components list", plus how to find the key and which algorithms are acceptable.
The reason is that Signature-Input is written by the sender. A valid signature over ("@method") proves the sender holds the key and said something about the method. It proves nothing about the path or the body. A verifier that checks the signature and does not compare the declared components against its own required list accepts a signature that binds almost nothing.
A written policy for an agent gateway then reads like a checklist: required components (@method, @path, @authority, content-digest), required parameters (created, expires, nonce, keyid), a maximum lifetime, an expected key for this agent, and a refusal for anything else. Refuse by default and say which rule failed, so the refusal can be recorded.
Parsing matters too. Structured Fields allow parameters on inner-list members. A header such as ("@method";a="@path") covers only @method, even though a text scan sees both names. A verifier has to parse the list, not search it.
Web Bot Auth fixes a profile for automated clients
RFC 9421 leaves the profile open. Web Bot Auth is one profile aimed at automated HTTP clients. Cloudflare's documentation, read on 3 October 2026, describes it as a way to "verify bot identity using cryptographic HTTP message signatures". It says to attach three headers, Signature, Signature-Input and Signature-Agent, to a bot's requests, that the tag parameter should equal web-bot-auth, and that "Cloudflare's implementation of Web Bot Auth does not support every component and parameter defined in IETF RFC 9421."
The profile is moving. The IETF datatracker lists draft-ietf-webbotauth-httpsig-protocol-00, an active working-group draft dated 1 September 2026. It requires created, expires, keyid (as a JWK SHA-256 thumbprint) and tag="web-bot-auth", and at least one of @authority or @target-uri. It recommends expiry "no more than 24 hours". It defines Signature-Agent as a Dictionary Structured Header whose members are strings containing https URIs, notes that earlier versions used a bare string, and says signers "MUST send the dictionary form". It also says implementations "MUST NOT use shared HMAC".
That last rule is a useful test for any agent scheme. If a verifier and a client share a secret, the verifier can forge requests, and a stolen verifier database is a set of working credentials. An asymmetric key avoids that.
Treat the draft's numbers as a snapshot. It is a -00 document and the earlier individual drafts it replaced have already expired.
Where Mandare fits, and what it does not do
Mandare's gateway (the "door") can require an RFC 9421 signature from each agent. The concept docs say agents "sign every request under their key (RFC 9421 HTTP Message Signatures, with a Content-Digest over the exact body bytes)", described under Passport in the concepts page. The implementation is the request-signature source file in the passport package, using the web-bot-auth npm package at version 0.1.3, whose npm metadata points to Cloudflare's web-bot-auth repository on GitHub. This applies to the CLI at v0.1.0, which npm view @mandarelabs/cli version reported on 3 October 2026.
What the code does, as it reads:
- The required covered components are
@method,@path,@authority,content-digestandsignature-agent. A signature that covers fewer is refused withCOMPONENTS_NOT_COVERED, and so is aSignature-Inputwhose inner-list members carry parameters. - The signing key must be the key the agent's delegation credential binds. A different key is refused with
WRONG_KEY. - The default signature lifetime is 30 seconds and the ceiling is 300 seconds. The nonce is claimed only after the signature verifies, so a bad probe cannot use up a real client's nonce.
- A body that does not match its digest is refused with
BODY_DIGEST_MISMATCH; a repeated nonce withREPLAYED_NONCE.
The unit tests in packages/passport/test/request-signature.test.ts cover each of those refusals. Once identity is proven, the gateway's later refusals are ledger entries, shown with spend caps in a runaway loop that dies at €20, and the ledger itself is described in why it holds proofs, not data.
Limits, stated next to what they limit:
- A request that fails authentication is not a ledger entry. The gateway's passport-mode test sends an unsigned request, expects
401with codePASSPORT_MISSING, and expects the ledger to hold zero entries. Refusals after identity is proven, such as a killed agent's call, are recorded with the agent's DID. - Passport mode is in the TypeScript SDK only. The SDK page says Python's standard library has no Ed25519, so the Python client supports token mode. Bodies that cannot be digest-signed exactly, such as streams and form data, are refused client-side.
- The
Signature-Agentheader carries the agent'sdid:keyas a quoted string, andkeyidis the SHA-256 hex of the raw public key. The draft requireshttpsURIs in a dictionary and a JWK thumbprint askeyid. We make no claim that a Mandare-signed request passes a Web Bot Auth verifier. - Token-mode proofs in the repo's published dead-paper demo do not bind the body. The threat model says so and states that passport mode closes the gap. The demo transcript in the repo covers token mode, not RFC 9421.
- A signature proves possession of a key at signing time. If the private key leaves the agent's host, the thief can sign valid requests until the credential is revoked. Revocation is a separate mechanism:
mandare kill <agent-did>is documented in the CLI reference.
Questions
Does RFC 9421 sign the request body?
Not by itself. Section 7.2.8 says the specification "does not provide coverage for the content of an HTTP message". You compute a Content-Digest (RFC 9530) over the body bytes and list content-digest among the covered components.
How do you stop a replayed signed request?
RFC 9421 section 7.2.2 names three tools: cover enough of the message, add a nonce parameter so the verifier can detect a repeat, and set created and expires to limit a captured signature's usefulness. The verifier has to remember the nonces it has seen.
Is a signed request safer than an API key?
It removes one failure: a copied header is not enough, because the thief also needs the private key. It does not help if the private key itself is stolen from the agent's host, so key storage and revocation still matter.
Is Web Bot Auth the same as RFC 9421?
No. Web Bot Auth is a profile built on RFC 9421: it fixes a tag value, a Signature-Agent header and a key directory. As of 3 October 2026 the profile is an active IETF working-group draft, not an RFC.
Sources
- RFC 9421: HTTP Message Signatures · IETF / RFC Editor · 2024-02-01
- RFC 9530: Digest Fields · IETF / RFC Editor · 2024-02-01
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage · IETF / RFC Editor · 2012-10-01
- Claude API overview (authentication headers) · Anthropic
- Web Bot Auth · Cloudflare
- HTTP Message Signatures for Automated Traffic (draft-ietf-webbotauth-httpsig-protocol-00) · IETF Web Bot Auth Working Group · 2026-09-01
Run it yourself: 3 commands, no API keys.
github.com/mandarelabs/mandare →