The signature on your webhook covers a string you built by hand
Open any webhook verification function you have written and look at what actually goes into the HMAC. It is almost never the HTTP request. It is a string you assembled: a timestamp, a dot, the raw body, maybe the event id, glued together in the exact order the provider documented. You recompute that string, run HMAC-SHA256 over it, and compare. The signature protects that blob and nothing else.
That works. But it is brittle in a specific way. The sender and the receiver have to agree, byte for byte, on how the blob gets built. Every provider invents its own recipe. Stripe concatenates one way, GitHub another, Shopify a third. You end up with a folder full of near-identical verifiers that differ only in string glue, and each one is a place to get the ordering wrong.
RFC 9421 takes a different position. Rather than sign a string you reconstruct, it signs named parts of the HTTP request itself, and it writes down in the message exactly which parts. That one move is why HTTP Message Signatures are showing up in webhook traffic, and why they are worth understanding before one lands on your endpoint.
What RFC 9421 actually signs
RFC 9421, published by the IETF in 2024, defines a general mechanism for signing components of an HTTP message. It is not webhook-specific. It covers requests and responses, and it is the same machinery whether you sign an API call, a webhook, or an agent's fetch.
The signer picks a set of covered components. Those can be real header fields, like content-type or content-digest, or derived components that describe the request without being headers. The derived ones start with an @: @method is the HTTP method, @path is the absolute path without the query, @authority is the host, @target-uri is the whole URL, @query is the query string. For a response you can cover @status.
The signer serializes each covered component into a canonical form, one per line, then appends a final @signature-params line describing the choice. That whole text is the signature base, and that is what gets signed. You never transmit the base. Both sides rebuild it from the request and the parameters. So the recipe is no longer buried in a provider's PDF. It is declared in the message.
Reading a Signature-Input line
Two header fields carry everything. Signature-Input says what was signed and how. Signature carries the bytes.
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest")
;created=1757000000;keyid="whv-2026-08";alg="ed25519";tag="webhook"
Signature: sig1=:wH9dQ0p6l0m3s2r...base64...==:
The label sig1 ties the two together and lets a message carry more than one signature. Inside the parentheses is the ordered list of covered components. After the semicolons come the parameters. created is a Unix timestamp. keyid names the key so you know which public key to verify against. alg is the algorithm. tag is an application label, where Web Bot Auth uses one and a webhook sender can set its own. There is also expires for a hard cutoff and nonce for replay tracking.
The colons wrapping the signature value are not decoration. RFC 9421 serializes byte sequences as Structured Fields, and :...: is the syntax for a base64 blob. Strip them before you decode.
Content-Digest is how the body gets covered
Notice what is missing from that component list: the body. RFC 9421 deliberately does not sign message content directly. It leans on a separate field, Content-Digest from RFC 9530, and you cover that field in the signature.
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
So the chain runs like this. The sender hashes the body into Content-Digest, then includes "content-digest" among the covered components. The signature now protects the body indirectly, because changing one byte of the payload changes the digest, which changes the signature base, which breaks verification.
This split is more useful than it looks. A proxy that only needs to confirm the body was untouched can check the digest without going near the signature. And because the digest is its own field, you verify it as its own step. Recompute the hash of the raw bytes you received, then compare. That gives you a cleaner failure boundary than finding a body mismatch only when the whole signature fails. The catch is obvious once you say it out loud. Forget to cover content-digest and the signature says nothing about the body at all, so an attacker can swap the payload freely.
Verifying an incoming signature
Verification is mechanical, and that is the point. Parse Signature-Input, rebuild the base in the declared order, look up the key by keyid, then verify. Here is the shape of it for an ed25519 signature. Treat it as illustration, not a drop-in library.
import crypto from 'node:crypto'
function buildBase(req, components, paramsLine) {
const lines = components.map((c) => {
const value =
c === '@method' ? req.method.toUpperCase() :
c === '@authority' ? req.headers['host'] :
c === '@path' ? new URL(req.url, 'https://x').pathname :
req.headers[c] // e.g. content-digest
return `"${c}": ${value}`
})
lines.push(`"@signature-params": ${paramsLine}`)
return lines.join('\n')
}
function verify(req, publicKeyPem) {
const base = buildBase(req, COVERED, PARAMS_INNER_LIST)
const sig = Buffer.from(unwrap(req.headers['signature']), 'base64')
return crypto.verify(null, Buffer.from(base), publicKeyPem, sig)
}
The crypto.verify call passes null as the algorithm because ed25519 encodes it in the key. What matters is that the base you build matches the base the signer built, character for character. Most failures here are not cryptographic at all. They come from a component you serialized slightly differently.
Why receivers are starting to see this
For years RFC 9421 was a spec without much webhook uptake. That changed once two adjacent pressures pushed it into request traffic.
The louder one is agent identity. Cloudflare's Web Bot Auth, rolling out through 2026, uses HTTP Message Signatures so an automated client can prove which operator it is, with a directory of public keys behind the keyid. The moment your service both receives webhooks and gets crawled or called by AI agents, you are looking at 9421 headers on inbound requests whether or not any webhook provider sends them.
The quieter one is event specs adopting it directly. The Universal Commerce Protocol, as of its 2026-04-08 revision, gives its webhooks a spec-defined, cryptographically verified shape built on RFC 9421 instead of a bespoke HMAC blob. That is the interesting signal for webhook receivers. A new event spec reached for the IETF standard rather than inventing recipe number forty-one. If you have already read our take on how signatures are consolidating in /blog/standard-webhooks-asymmetric-signature-verification, this is the other pole of that same consolidation.
How it differs from Standard Webhooks
Standard Webhooks and RFC 9421 both want to end the per-provider signing zoo, and it helps to see where they part ways. Standard Webhooks defines a single header, webhook-signature, over a fixed string of id.timestamp.body. It is deliberately small. One recipe, easy to implement, and it maps cleanly onto what most providers already did.
RFC 9421 is more general and more explicit. It does not fix the recipe. It makes the sender declare it per message in Signature-Input. That flexibility is its strength and its cost. A Standard Webhooks verifier is a dozen lines because the recipe never changes. A fully general 9421 verifier has to parse Structured Fields, handle any component set, and canonicalize each one correctly. Most senders settle on a narrow, boring profile, and you should verify against exactly that profile rather than build a universal parser you do not need.
The parts that bite receivers
Canonicalization is where good intentions die. A derived component like @authority has normalization rules, such as a lowercased host and default ports dropped, and if your reconstruction disagrees with the signer's by one character, verification fails with no useful error. Header field values get their own trimming and combination rules when a field shows up more than once.
Then there are proxies. @path and @authority describe the request as the signer saw it. If a load balancer rewrites the path, strips a prefix, or terminates TLS and forwards to a different host header, the request your handler sees is not the request that was signed. This is the same class of problem that makes IP allowlisting fragile behind a proxy, covered in /blog/webhook-ip-allowlisting, and it has the same fix. Verify against the values as they arrived at your trust boundary, and make sure nothing between the edge and your verifier mutates a covered component.
Finally, created and expires are inputs you have to enforce. A valid signature is still a replay if you accept an old created timestamp, so keep a freshness window and track nonce values. Signature verification alone has never been enough against replay, a point worth revisiting in /blog/webhook-security-verification-replay-attacks.
Algorithms and where the keys come from
RFC 9421 registers a spread of algorithms: ed25519, ecdsa-p256-sha256, ecdsa-p384-sha384, hmac-sha256, rsa-pss-sha512, and rsa-pkcs1v15-sha256. The presence of hmac-sha256 matters. You can run 9421 with a shared secret exactly like a classic webhook, keeping the symmetric model while you gain the declared-components structure.
The reason to care, though, is the asymmetric options. With ed25519 or an ECDSA curve, the sender holds a private key and you verify with a public one. You store no secret that can leak from your side and be turned into forged events. The keyid parameter tells you which public key to use, which is what lets a sender rotate keys or publish a directory without coordinating a shared secret with every receiver. That is the same shift toward public-key verification reshaping webhook auth generally, and it is the part of 9421 that earns its complexity.
What to do when both formats show up
You will not flip a switch. For a while your endpoint receives classic HMAC blobs from old providers, maybe a Standard Webhooks header from newer ones, and RFC 9421 signatures from agents and event specs that adopted it. Forcing everything through one verifier is how you introduce bugs.
Detect the format first, then dispatch. Presence of Signature-Input means 9421. A webhook-signature header means Standard Webhooks. A provider-specific header means the legacy path. Keep each verifier narrow and give each its own freshness and replay checks rather than share one loose window. Treat an unrecognized or missing signature as a hard reject, never a warning you log and move past. An endpoint that accepts unsigned requests during a migration is an endpoint an attacker will find. Add 9421 as a clean, separate path so that when a sender you depend on moves to it, your receiver already speaks it.
Frequently asked questions
Do I need a full RFC 9421 library to verify one sender? No, and you probably should not build one. A general verifier has to parse Structured Fields and handle any component set, but a single sender uses a fixed, narrow profile. Rebuild the base for exactly the components that sender declares, verify against their published key, and reject anything whose Signature-Input does not match the profile you expect.
Does covering content-digest mean the body is signed? Only if you actually list content-digest among the covered components and you verify the digest against the raw bytes you received. RFC 9421 never signs the body directly. If you skip the digest field, the signature protects the method and path but says nothing about the payload, which means an attacker can change the body without breaking verification.
Can RFC 9421 use a shared secret like my current HMAC webhook? Yes. The hmac-sha256 algorithm is registered, so you can run message signatures symmetrically and keep the shared-secret model. You gain the declared-components structure and lose the per-provider string recipe, but you do not automatically get the leak resistance of public keys. That benefit only arrives with an asymmetric algorithm like ed25519 or ecdsa-p256-sha256.
Why did verification fail when the signature looks correct? Almost always because your signature base does not match the signer's. A derived component like authority or path was normalized differently, a repeated header was combined in the wrong order, or a proxy rewrote a covered value before your handler saw it. Log the exact base string you built and compare it byte for byte against what the sender documents. The mismatch is usually one character.
Is this only relevant if a webhook provider adopts it? No. The faster route onto your endpoint is inbound agent traffic. Schemes like Web Bot Auth sign requests with HTTP Message Signatures to prove operator identity, so any service that both receives webhooks and gets called by automated agents will see 9421 headers regardless of what its webhook providers do.