Syed Hasibur Rahman23 min read
HTTP Message Signatures: How RFC 9421 Turned a Decade of Ad Hoc Signing Into One Spec
Before February 2024, every API vendor invented its own way to sign an HTTP request: AWS built SigV4, Stripe and GitHub built timestamp-plus-HMAC webhook headers, and the fediverse ran on an expired IETF draft that let an attacker swap the algorithm field without invalidating the signature. RFC 9421 replaces all of it with one canonical mechanism — a deterministic signature base built from named HTTP components, two structured headers to carry it, and a registry of interchangeable algorithms. This post builds a signer and verifier from scratch, walks through Mastodon's bumpy real-world migration off the old Cavage draft, and covers Web Bot Auth — the live IETF effort using RFC 9421 to give AI agents a verifiable identity on the open web.
Somewhere in every backend team’s history is the afternoon someone had to wire up three webhook receivers in the same sprint. GitHub sends X-Hub-Signature-256: sha256=<hex hmac> over the raw body1. Stripe sends Stripe-Signature: t=<timestamp>,v1=<hex hmac>, where the HMAC covers timestamp.payload, not the payload alone2. AWS wants none of that — it wants a canonical request string, a credential scope, and a signature computed through four rounds of HMAC key derivation before you’re allowed to call an S3 API3. Three vendors, three ways to prove “this request really came from us,” three slightly different bugs waiting in the raw-body-parsing middleware. None of the three schemes is wrong, exactly. They’re just each other’s cousins, independently invented, incompatible by construction, and every one of them was designed by a team solving this problem for the first time with no shared vocabulary to borrow.
That is the state HTTP request signing was in for over a decade. Meanwhile a separate, quieter version of the same problem was rotting in production across an entire federated social network: ActivityPub servers — Mastodon, Pleroma, and friends — have been signing every server-to-server delivery since 2017 using an IETF Internet-Draft that expired in 2020, was never adopted by a working group, and never became an RFC4. It kept working because “expired draft” doesn’t stop git clone, but it carried a real flaw: the draft’s signing string didn’t cover the algorithm or key identifier, so those fields could be tampered with in transit without invalidating the signature5.
RFC 9421, published by the IETF in February 2024, is the standard that was supposed to exist all along6. It does not solve a new problem. It solves the oldest problem in API security — “prove this HTTP message came from who it says it came from, and wasn’t altered in flight” — with one canonical, versioned, cryptographically rigorous mechanism instead of N incompatible ones. This post is a from-scratch build of that mechanism: how the signature base is canonicalized, what the two headers that carry it actually contain, why Mastodon’s real 2025 migration is the best case study available for what the old approach got wrong, and why Cloudflare, OpenAI, Akamai, and an active IETF working group are now betting RFC 9421 becomes the way AI agents prove who they are to the rest of the web.
Part 1: The ad hoc landscape RFC 9421 was written to end
Before looking at the spec, it’s worth cataloguing exactly what it replaces, because the replacement only makes sense in contrast.
AWS Signature Version 4. Every authenticated call to an AWS API — S3, DynamoDB, Bedrock — is signed with SigV4: build a canonical request (method, path, canonicalized query string, canonicalized headers, hashed body), hash it, derive a signing key by four chained HMAC operations over the date, region, service, and a fixed aws4_request string, then HMAC the canonical request’s hash with that derived key3. It is well-documented, battle-tested at enormous scale, and entirely proprietary to AWS’s ecosystem. Nobody outside AWS’s orbit implements SigV4 for their own API, because there’s no reason to — it’s AWS’s scheme, not a public standard.
Webhook HMAC-over-body-plus-timestamp. GitHub, Stripe, Shopify, Slack, and dozens of SaaS platforms independently converged on a shape: HMAC-SHA256 over some combination of the raw request body and a timestamp, delivered in a custom header, with the receiver expected to do a constant-time comparison and reject anything outside a tolerance window (Stripe defaults to 300 seconds)27. GitHub’s version signs the raw body alone and relies on TLS plus the delivery being synchronous for freshness; Stripe’s includes the timestamp inside the signed material specifically to stop replay1. Both work. Neither is interoperable with the other, and a new SaaS vendor building yet another webhook sender in 2026 has to choose, from scratch, which flavor of ad hoc HMAC to reinvent.
draft-cavage-http-signatures. Mark Cavage and Manu Sporny’s Internet-Draft, last revised as version 12 in 2019, expired on April 22, 2020, having never been adopted by an IETF working group4. It defined a Signature header carrying keyId, algorithm, a list of signed headers, and the signature value, with the signing string built by concatenating the named headers — including a synthetic (request-target) pseudo-header for the method and path. It is, today, still the most widely deployed HTTP-signing mechanism on the internet, because the entire ActivityPub fediverse — Mastodon foremost among it — adopted it as the mechanism for authenticating server-to-server deliveries and never had a reason to move, until they did5.
Three ecosystems, three schemes, zero interoperability, and — in the Cavage draft’s case — a real security gap: the algorithm and key identifier were never part of the signed data. An attacker with write access anywhere in the delivery path could rewrite algorithm from a strong scheme to a weak one, or swap keyId to point at a different key, and the receiving server would still see a signature that verifies — because that verification never covered the fields describing how to verify it5.
RFC 9421 was drafted inside the IETF’s HTTPBIS working group by Annabelle Backman (Amazon), Justin Richer (Bespoke Engineering), and Manu Sporny (Digital Bazaar) — Sporny is also a co-author of the original Cavage draft, so this is in a real sense the same author fixing his own decade-old design6.
Part 2: What RFC 9421 actually is, in one paragraph
RFC 9421 defines three things that click together: a way to name and canonicalize pieces of an HTTP message (“components”), a way to build a deterministic string from a chosen set of those components (the “signature base”), and a pair of HTTP fields — Signature-Input and Signature — that carry which components were covered, what metadata accompanied the signature, and the signature value itself6. Crucially, the metadata line describing the signature — algorithm, key ID, creation time, expiry, nonce — is itself one of the lines fed into the signature. Change any of it after the fact, and the signature breaks. That single design choice closes the Cavage draft’s algorithm-tampering gap by construction.
The spec is explicit that it is a mechanism, not a policy: “applications that make use of this document are expected to specify the parameters that are used, including the message components covered, the parameters used, and the algorithms allowed”6. RFC 9421 gives you the tool. Web Bot Auth, Mastodon, and any internal API you build with it each define their own profile — which components must be signed, which algorithms are allowed, how keys are discovered. That’s a deliberate separation, and it’s why the same spec underpins both a fediverse server-to-server handshake and an AI agent proving its identity to a news site, with none of the shared plumbing duplicated.
Part 3: Canonicalizing an HTTP message — the signature base
The hard part of any request-signing scheme is turning a fuzzy thing (an HTTP message, possibly transformed by a proxy on the way) into an exact string two parties will compute identically. RFC 9421 handles this with component identifiers, which come in two flavors6.
HTTP field components are ordinary header names, lowercased. If a field appears more than once, its values are combined with ", " per standard HTTP semantics before being used.
Derived components start with @ and are computed from the message rather than copied from a header, because plenty of the material worth signing — the method, the path, the response status — doesn’t live in a header at all6:
| Component | Meaning |
|---|---|
@method | The HTTP method, uppercase (GET, POST) |
@target-uri | The full target URI of the request |
@authority | The normalized authority (lowercased host, default port omitted) |
@path | The absolute path |
@query | The query string, including the leading ? |
@query-param | An individual named query parameter |
@status | The three-digit response status code (responses only) |
Each covered component becomes one line of the signature base, formatted as "component-name": value, in the exact order the signer chose. The final line is always "@signature-params": <the serialized metadata> — never included as a component itself, always present, always last6. That ordering and format is not negotiable; both signer and verifier must land on byte-identical strings, or every signature fails.
Here’s what a signature base looks like for a POST request signing four components:
"@method": POST
"@target-uri": https://api.example.com/webhooks/orders
"content-type": application/json
"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"@signature-params": ("@method" "@target-uri" "content-type" \
"content-digest");created=1723600200;keyid="aice-webhooks-2026-08";alg="ed25519"
Every line the signer includes, the verifier must reconstruct from the message it actually received — using the same canonicalization rules — and the two strings must match exactly before the cryptographic check even runs. This is what makes RFC 9421 robust to the exact class of intermediary tampering the Cavage draft’s undefended metadata line was vulnerable to: change the algorithm, the key ID, the created time, or which components were claimed to be signed, and the reconstructed @signature-params line no longer matches what was signed, so the signature fails before any key material is even consulted.
Part 4: Signature-Input and Signature — the two headers
The signature base above never travels on the wire. What travels is two headers, both using the Structured Field Values syntax from RFC 8941 (Nottingham and Kamp, February 2021) — a standardized grammar for HTTP header values with dictionaries, lists, tokens, and byte sequences, designed specifically to replace the custom ad hoc parsers every “clever” header format used to require8.
Signature-Input is a Dictionary whose members are labeled signatures, each an Inner List of quoted component identifiers followed by parameters:
Signature-Input: sig1=("@method" "@target-uri" "content-type" \
"content-digest");created=1723600200;keyid="aice-webhooks-2026-08";alg="ed25519"
Signature is a Dictionary mapping the same label to the signature bytes, wrapped as a Structured Field Byte Sequence (:-delimited base64):
Signature: sig1=:MEUCIQDx7yq3f8jvW0rN...base64...IUcQ==:
A message can carry multiple signatures under different labels — useful when a proxy needs to add its own signature on top of the origin’s, or when a message is co-signed by two parties6. The metadata parameters available on each signature are: created (Unix timestamp, when the signature was generated), expires (Unix timestamp after which the signature must be rejected), nonce (a unique value for replay resistance), alg (the algorithm identifier), keyid (which key to verify against), and tag (an application-defined label distinguishing signature purposes, e.g. separating a Web Bot Auth signature from an unrelated payment signature on the same message)6.
Part 5: Content-Digest — signing a body without embedding it
Signing headers is one thing; signing the body of a POST is another, because you don’t want to inline a multi-megabyte payload into the signature base. RFC 9421’s companion spec, RFC 9530 Digest Fields, published the same month (February 2024) by the same working group, defines the Content-Digest field: a Structured Field Dictionary mapping a digest algorithm name to the digest, base64-encoded9.
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Content-Digest obsoletes the older Digest and Want-Digest headers from RFC 3230 (2002), which predated Structured Fields and had never been widely or consistently implemented9. The pattern in an RFC 9421 deployment is: compute Content-Digest over the actual body, add that header to the request like any other, then include content-digest as one of the signed components. The signature never contains the body directly — it contains a hash of it, which is exactly what Mastodon 4.4’s implementation does, and exactly why the spec authors called out replacing the old Digest header as a deliberate modernization rather than a side effect10.
Part 6: Algorithms — a registry instead of a house style
RFC 9421 §3.3 defines the initial algorithm registry, extensible via IANA6:
| Identifier | Scheme |
|---|---|
rsa-pss-sha512 | RSASSA-PSS using SHA-512 |
rsa-v1_5-sha256 | RSASSA-PKCS1-v1_5 using SHA-256 |
ecdsa-p256-sha256 | ECDSA using curve P-256 and SHA-256 |
ecdsa-p384-sha384 | ECDSA using curve P-384 and SHA-384 |
ed25519 | EdDSA using curve edwards25519 |
hmac-sha256 | HMAC using SHA-256 (symmetric — shared-secret use cases) |
This is the part of the spec that quietly does the most work: it means a webhook sender and a fediverse server and an AI-agent operator can all use the exact same header syntax and canonicalization rules while choosing different, purpose-appropriate algorithms — Ed25519 for lightweight agent identity, RSA-PSS where an existing PKI already issues RSA keys, HMAC where the two parties share a secret out of band and asymmetric crypto is overkill. Mastodon’s current implementation, notably, supports only rsa-v1_5-sha256, “the only one supported by the keys we currently use in Mastodon” — a pragmatic, not spec-mandated, constraint10.
What the spec does not solve is key distribution — how a verifier finds the right public key for a given keyid in the first place. That gap is being filled by a separate, still-in-progress IETF individual draft, draft-meunier-http-message-signatures-directory, which defines a JWKS-based directory format and a .well-known/http-message-signatures-directory endpoint, plus a Signature-Agent header pointing verifiers at where to look11. It’s exactly the piece Web Bot Auth (Part 8) needed to exist before it could ship.
Part 7: Case study — what Mastodon’s real migration exposed
Theory aside, the most instructive real-world data point on RFC 9421 adoption is Mastodon’s, because it’s public, incremental, and ongoing as of this writing.
Mastodon’s federation protocol, ActivityPub, has run entirely on the expired Cavage draft since the project’s early years. A GitHub issue asking “could it be a good idea to move to the standard?” was opened in April 2024, two months after RFC 9421 published12. It took over a year to land code: PR #34814, merged June 2, 2025, added inbound verification only — Mastodon could now check an RFC 9421 signature on incoming requests, but still could not produce one on outbound requests10. The PR’s own description explains the two required derived components bluntly: @method and @target-uri must be signed, “as this combination makes sure the signature matches the request actually made” — a direct, practical closing of the exact gap the Cavage draft left open.
The rollout was staged deliberately conservatively:
- Mastodon 4.4 (July 2025): RFC 9421 verification shipped behind an experimental feature flag,
EXPERIMENTAL_FEATURES=http_message_signatures. Verify only, not sign. Administrators had to opt in explicitly13. - Mastodon 4.5: the feature flag was removed and verification became default behavior.
- Later releases: outbound signing using RFC 9421 was added — but Mastodon still sends the legacy Cavage-12 signature first, for compatibility with the large population of fediverse software that hasn’t migrated yet, with the RFC 9421 signature layered alongside it14.
That last detail is the real lesson. A cryptographic-signing migration across a federated network with no central authority cannot be a cutover — it has to be a long-tail dual-stack period where the old and new schemes coexist on every message, because you cannot force ten thousand independently-operated servers to upgrade on your schedule. RFC 9421’s use of distinct header names (Signature-Input/Signature vs. the Cavage draft’s single Signature header with different internal structure) is what makes that coexistence mechanically possible at all — a receiver that only understands one scheme simply ignores the header it doesn’t recognize, rather than choking on a malformed one.
Part 8: Case study — Web Bot Auth and cryptographic identity for AI agents
The fastest-moving real deployment of RFC 9421 right now isn’t webhooks or fediverse federation — it’s bot and agent identity, and it’s directly relevant to anyone building or gating access for LLM-driven traffic.
The problem. For thirty years, sites have tried to tell “legitimate crawler” from “everything else” using the User-Agent string and IP allowlists. Both are trivially spoofable — a User-Agent header is just a string an HTTP client chooses to send — and IP-based validation is, in Cloudflare’s own framing, “brittle”: address ranges change, get shared across VPNs and privacy proxies, and don’t reliably map to a specific operator at all15. As AI agents increasingly make autonomous HTTP requests on a user’s behalf — fetching pages, filling forms, calling APIs — sites need a way to know which agent, operated by whom, is actually knocking, without trusting a string the client fabricated.
The mechanism. Web Bot Auth, publicly proposed by Cloudflare in May 2025, layers directly on RFC 9421: the agent operator generates an Ed25519 keypair, publishes the public key at a well-known JWKS-style location (Cloudflare’s post gives crawler.search.google.com and operator.openai.com as illustrative examples), and signs every outbound request, including a Signature-Agent header that tells the receiving site where to fetch the verification key15. The signature covers the request’s authority and the Signature-Agent header itself, so the claimed identity can’t be detached from the request that carries it.
OpenAI is an explicit, named adopter. Their own description of the motivation: “Ensuring the authenticity of Operator traffic is paramount. With HTTP Message Signatures (RFC 9421), OpenAI signs all Operator requests so site owners can verify they genuinely originate from Operator”15. Cloudflare has since folded message-signature verification into its Verified Bots program, and the IETF chartered a dedicated working group, webbotauth, in 2026, chaired by David Schinazi and Rifaat Shekh-Yusef, with milestones targeting a standards-track authentication specification in April 2026 and a Best Current Practice operational document in August 202616. As of this writing the working group’s charter is approved and active, but it has not yet adopted a formal working-group document — only individual drafts are in circulation, even as Cloudflare, AWS WAF, Akamai, and other infrastructure providers already verify these signatures in production to decide which automated traffic reaches origin servers17.
This is, in miniature, exactly the pattern RFC 9421 was built to enable: a brand-new, high-stakes trust problem — proving an HTTP request came from a specific AI agent operator, not an impersonator — solved by composing the existing canonicalization and signature-carrying mechanism with a purpose-built profile (which components must be signed, Ed25519 as the required algorithm, the key-directory convention), rather than inventing yet another bespoke header format from nothing.
Part 9: Building a signer and verifier
Here is a from-scratch Python implementation covering signature base construction, Content-Digest, signing with Ed25519, and verification — the same level of “make the moving parts visible, skip the production library” as any spec walkthrough is worth doing. In production, reach for an existing implementation — http-message-signatures on PyPI is a maintained RFC 9421 implementation — but understanding what it does under the hood is the point of this exercise18.
import base64
import hashlib
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey, Ed25519PublicKey,
)
# --- Content-Digest (RFC 9530) ---
def content_digest(body: bytes) -> str:
digest = base64.b64encode(hashlib.sha256(body).digest()).decode()
return f"sha-256=:{digest}:"
# --- Signature base construction (RFC 9421 §2.5) ---
def build_signature_base(covered: list[str], request: dict, params_line: str) -> str:
"""
`request` supplies both derived-component values (keys starting with '@')
and ordinary field values (lowercase header names), already combined per
HTTP semantics if repeated.
"""
lines = []
for name in covered:
value = request[name]
lines.append(f'"{name}": {value}')
lines.append(f'"@signature-params": {params_line}')
return "\n".join(lines)
def serialize_params(covered: list[str], created: int, keyid: str,
alg: str, expires: int | None = None,
nonce: str | None = None) -> str:
quoted = " ".join(f'"{c}"' for c in covered)
parts = [f"({quoted})", f"created={created}"]
if expires is not None:
parts.append(f"expires={expires}")
if nonce is not None:
parts.append(f'nonce="{nonce}"')
parts.append(f'keyid="{keyid}"')
parts.append(f'alg="{alg}"')
return ";".join(parts)
# --- Signing ---
def sign_request(method: str, target_uri: str, headers: dict, body: bytes,
private_key: Ed25519PrivateKey, keyid: str,
ttl: int = 300) -> dict:
digest = content_digest(body)
headers = {**headers, "content-digest": digest}
covered = ["@method", "@target-uri", "content-type", "content-digest"]
request_view = {
"@method": method.upper(),
"@target-uri": target_uri,
"content-type": headers["content-type"],
"content-digest": digest,
}
created = int(time.time())
expires = created + ttl
params_line = serialize_params(covered, created, keyid, "ed25519", expires)
base = build_signature_base(covered, request_view, params_line)
signature = private_key.sign(base.encode())
sig_b64 = base64.b64encode(signature).decode()
headers["signature-input"] = f"sig1={params_line}"
headers["signature"] = f"sig1=:{sig_b64}:"
return headers
# --- Verification ---
class VerificationError(Exception):
pass
def verify_request(method: str, target_uri: str, headers: dict, body: bytes,
public_key: Ed25519PublicKey, max_age: int = 300) -> None:
expected_digest = content_digest(body)
if headers.get("content-digest") != expected_digest:
raise VerificationError("content-digest does not match body")
sig_input = headers["signature-input"]
label, params_line = sig_input.split("=", 1)
covered_str = params_line[1:params_line.index(")")]
covered = [c.strip('"') for c in covered_str.split()]
request_view = {
"@method": method.upper(),
"@target-uri": target_uri,
"content-type": headers["content-type"],
"content-digest": headers["content-digest"],
}
base = build_signature_base(covered, request_view, params_line)
sig_field = headers["signature"]
sig_b64 = sig_field.split(":")[1]
signature = base64.b64decode(sig_b64)
try:
public_key.verify(signature, base.encode())
except Exception as e:
raise VerificationError(f"signature does not verify: {e}") from e
created = int(_extract_param(params_line, "created"))
expires_str = _extract_param(params_line, "expires")
now = int(time.time())
if expires_str is not None and now > int(expires_str):
raise VerificationError("signature expired")
if now - created > max_age:
raise VerificationError("signature older than max_age")
def _extract_param(params_line: str, name: str) -> str | None:
for part in params_line.split(";")[1:]:
if part.startswith(f"{name}="):
return part.split("=", 1)[1].strip('"')
return None
A few things worth flagging about what this leaves out, deliberately, to stay readable: real implementations parse Structured Field Dictionaries with a proper RFC 8941 parser rather than string-splitting (repeated field values, whitespace variation, and multiple simultaneous signatures under different labels all need it); they track nonce values server-side to reject exact replays inside the expires window, the same way DPoP tracks jti; and they validate alg against an explicit allow-list per endpoint rather than trusting whatever the sender claims, exactly as RFC 9421 §1.4 requires applications to define6.
Part 10: Security considerations the spec is explicit about
RFC 9421 is unusually candid, in its own security-considerations section, about what it does and does not guarantee6.
Signing alone is not authentication. A verified signature proves the message wasn’t altered after signing and was signed by whoever holds the private key behind keyid — it says nothing about whether that key should be trusted for the operation being attempted. That trust decision belongs entirely to the application profile: Web Bot Auth trusts a key because it’s published at the operator’s own well-known directory endpoint; Mastodon trusts a key because it’s the actor’s published ActivityPub public key. RFC 9421 explicitly disclaims being “sufficient in creating an overall security story” by itself6.
Canonicalization must match exactly, including across proxies. If an intermediary reorders headers, changes casing, or normalizes whitespace in a component the signature covers, verification breaks — correctly, since the message did change, but this means signed components should be chosen defensively: sign what’s stable across your actual deployment topology, not everything that happens to be present.
Replay is defended by created/expires/nonce, not by the signature itself. A captured, still-valid signed request is fully replayable until it expires unless the verifier separately tracks nonces or nonce-equivalent state — the exact same shape of problem DPoP solves for OAuth tokens with its own jti-tracking and server-nonce mechanisms.
The metadata-tampering fix is the headline improvement over Cavage, and it’s worth restating precisely: because @signature-params — the line carrying alg, keyid, created, expires, nonce — is itself always the final signed line, any post-signing edit to any of that metadata invalidates the signature. This closes exactly the downgrade vector that made the Cavage draft’s design a real, not theoretical, weakness5.
Key discovery and rotation remain application problems. RFC 9421 tells you how to reference a key by keyid; it does not tell you how to fetch, rotate, or revoke that key. That’s precisely the gap draft-meunier-http-message-signatures-directory and Web Bot Auth’s .well-known convention exist to fill, and it’s the part of this ecosystem still most in flux as of mid-202611.
Part 11: RFC 9421 versus what it replaces
| Property | AWS SigV4 | Webhook HMAC (Stripe/GitHub-style) | Cavage draft | RFC 9421 |
|---|---|---|---|---|
| Public standard | No (AWS-proprietary) | No (per-vendor) | Expired individual draft | IETF Proposed Standard |
| Metadata (alg/keyid) signed | Yes (in canonical request) | N/A (single fixed algorithm) | No | Yes |
| Response signing | No | No | No | Yes (@status) |
| Body integrity | Inline hash in canonical request | HMAC over raw body | Optional Digest header | Content-Digest (RFC 9530) |
| Multiple simultaneous signatures | No | No | No | Yes, labeled |
| Algorithm agility | Fixed (HMAC-SHA256) | Fixed per vendor | RSA/HMAC/EdDSA, unsigned choice | Registry, signed choice |
| Interoperable across vendors | No | No | Within fediverse only | Yes, by design |
Part 12: What to actually do
- New webhook or API-signing designs should start from RFC 9421, not from a bespoke HMAC-over-body scheme. The spec gives you replay protection (
created/expires/nonce), tamper-evident metadata, and body integrity viaContent-Digestfor free, in a format every future integrator can parse with an off-the-shelf library instead of your bespoke docs page. - If you operate ActivityPub-federated software, plan for a long dual-stack period, not a cutover. Mastodon’s own migration — verify-only behind a flag, then default-on verify, then dual-signing on the outbound side — is the realistic template; a federated network with no central authority cannot be forced onto a new scheme on anyone’s schedule but its own.
- If you’re gating traffic from AI agents, evaluate Web Bot Auth now, understanding it is pre-standard. The underlying primitive (RFC 9421) is stable and ratified; the profile built on top of it (Signature-Agent, the key-directory format) is still moving through IETF, with milestones through August 2026. Cloudflare, Akamai, and AWS WAF are already enforcing it in production ahead of formal adoption — a reasonable bet, not a certainty.
- Choose the algorithm for the deployment shape, not out of habit. Ed25519 for lightweight agent/service identity where you control both ends; RSA-PSS or RSA-v1.5 where an existing PKI already issues RSA keys (as Mastodon’s did); HMAC-SHA256 only where the two parties genuinely share a secret out of band and asymmetric key management would be pure overhead.
- Track nonces or use short
expireswindows — the signature does not stop replay by itself. This is the same lesson OAuth DPoP teaches for a different transport; the pattern of “short-lived signed proof plus server-tracked freshness state” recurs because it’s the only mechanism that actually closes a replay window without requiring either party to trust the other’s clock unconditionally. - Don’t sign everything — sign what’s stable across your actual proxy topology. A component that a load balancer or CDN normalizes between signer and verifier will break verification correctly but confusingly; test signature verification behind your real infrastructure, not just directly against origin.
Closing
The interesting thing about RFC 9421 is how unglamorous its core idea is. It doesn’t introduce a new cryptographic primitive, a new trust model, or a new way of thinking about identity. It takes a problem that a dozen companies and one entire federated protocol had each solved slightly differently — and, in the fediverse’s case, solved with a real, load-bearing flaw sitting in production for the better part of a decade — and writes down the version that should have existed from the start: canonicalize the message the same way every time, sign the metadata along with everything else, and let applications choose their own algorithm and key-discovery story on top of a shared foundation.
That is exactly the kind of standard that takes years to matter and then, abruptly, is everywhere. Mastodon’s migration shows what adoption actually costs a live, decentralized system — feature flags, staged defaults, years of dual-signing, not a flag day. Web Bot Auth shows what adoption looks like when the pressure is acute and the standard is only half-finished — Cloudflare, Akamai, and OpenAI shipping against draft specifications because the alternative, letting every AI agent claim any identity it likes via a spoofable User-Agent string, is worse than moving early.
If you are building anything that signs an HTTP request in 2026 — a webhook sender, an internal service mesh, an agent that needs to prove who sent a request — the ad hoc option is still available. It is just no longer the reasonable default. RFC 9421 is.
References
GitHub, “Validating webhook deliveries”. https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries ↩︎ ↩︎
Stripe, “Register your webhook endpoint / verify webhook signatures”. https://docs.stripe.com/webhooks ↩︎ ↩︎
Amazon Web Services, “Authenticating Requests: Using the Authorization Header (AWS Signature Version 4)”. https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html ↩︎ ↩︎
Cavage, M. and Sporny, M., “Signing HTTP Messages”, IETF Internet-Draft draft-cavage-http-signatures-12 (expired April 22, 2020). https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 ↩︎ ↩︎
vitalvas, “Understanding HTTP Message Signatures”, December 2025. https://blog.vitalvas.com/post/2025/12/12/understanding-http-message-signatures/ ↩︎ ↩︎ ↩︎ ↩︎
Backman, A., Richer, J., and Sporny, M., “HTTP Message Signatures”, IETF RFC 9421, February 2024. https://www.rfc-editor.org/rfc/rfc9421.html ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Hooklistener, “Stripe Webhook Security Guide — Signature Verification, Testing & Best Practices”. https://www.hooklistener.com/learn/stripe-webhook-security-guide ↩︎
Nottingham, M. and Kamp, P-H., “Structured Field Values for HTTP”, IETF RFC 8941, February 2021. https://www.rfc-editor.org/rfc/rfc8941.html ↩︎
Pardue, L. and Nottingham, M., “Digest Fields”, IETF RFC 9530, February 2024. https://www.rfc-editor.org/rfc/rfc9530.html ↩︎ ↩︎
oneiros, “Accept HTTP Message Signatures (RFC9421)”, mastodon/mastodon Pull Request #34814, merged June 2, 2025. https://github.com/mastodon/mastodon/pull/34814 ↩︎ ↩︎ ↩︎
Meunier, T., “HTTP Message Signatures Directory”, IETF Internet-Draft draft-meunier-http-message-signatures-directory-05, 2026. https://datatracker.ietf.org/doc/draft-meunier-http-message-signatures-directory/ ↩︎ ↩︎
bortzmeyer, “Moving signatures to the standard (RFC 9421)?”, mastodon/mastodon GitHub issue #29905, April 2024. https://github.com/mastodon/mastodon/issues/29905 ↩︎
Mastodon gGmbH, “Mastodon 4.4 for developers”, Mastodon Blog, July 2025. https://blog.joinmastodon.org/2025/07/mastodon-4-4-for-devs/ ↩︎
Aggregated release/PR history for Mastodon RFC 9421 outbound signing (4.5 default-on verification; later releases add outbound RFC 9421 signing alongside legacy Cavage-12 for fediverse compatibility). https://github.com/mastodon/mastodon/pull/34814 ↩︎
Cloudflare, “Forget IPs: using cryptography to verify bot and agent traffic”, Cloudflare Blog, May 15, 2025. https://blog.cloudflare.com/web-bot-auth/ ↩︎ ↩︎ ↩︎
IETF, “Web Bot Auth (webbotauth)” Working Group charter and milestones, 2026. https://datatracker.ietf.org/wg/webbotauth/about/ ↩︎
Coronium, “Web Bot Auth in 2026: How Cryptographically Signed AI Agents Change the Closing Web”. https://www.coronium.io/blog/web-bot-auth-verifiable-ai-agents-2026 ↩︎
pyauth, “http-message-signatures — An implementation of RFC 9421, HTTP Message Signatures”, GitHub / PyPI. https://github.com/pyauth/http-message-signatures ↩︎