RFC 9421 and the Future of Webhook Signatures: What's Actually Changing
RFC 9421 and the Future of Webhook Signatures: What's Actually Changing For over a decade, event-driven web architecture has relied on an uncoordinated patchwork of proprietary...

RFC 9421 and the Future of Webhook Signatures: What's Actually Changing
For over a decade, event-driven web architecture has relied on an uncoordinated patchwork of proprietary security mechanisms. Stripe, GitHub, Shopify, Twilio, and dozens of other API vendors each invented their own scheme for signing webhook payloads. Developers receiving webhooks have had to write, test, and maintain a separate verification routine for every upstream provider in their stack.
In February 2024, the IETF published RFC 9421: HTTP Message Signatures as a Proposed Standard, alongside its companion RFC 9530: Digest Fields for representing a request body's hash. Together they define a vendor-neutral way to sign arbitrary parts of an HTTP exchange — not just the body.
That part of the story is well established. What's less often said plainly: webhook signing in production has not actually moved to RFC 9421 yet. As of 2026, Stripe still signs webhooks with its original Stripe-Signature HMAC scheme, and GitHub still uses X-Hub-Signature-256, exactly as both have for years. A Spring Security feature request opened in January 2026 to add RFC 9421 support notes plainly that adoption "is still emerging" and that popular webhook platforms like GitHub continue to define their own ad-hoc signature schemes. This article covers both halves of that picture: why RFC 9421 is technically the better design, and where it's realistically being used today versus where the old HMAC patchwork is still the norm.
1. The Flaws of Legacy Webhook Signing
To understand why the industry is even talking about a shared standard, it helps to look at how legacy HMAC schemes work and where they fall short.
How Legacy Webhooks Work
In a typical implementation — this is exactly what Stripe and GitHub do today — the sender computes an HMAC over the raw request body using a shared secret and attaches it to a custom header:
Signature = HMAC-SHA256(Secret, RawBody)
Stripe sends this as Stripe-Signature: t=<timestamp>,v1=<sig>, with the HMAC computed over timestamp.raw_body so a captured signature can't be replayed indefinitely. GitHub sends X-Hub-Signature-256: sha256=<hex>, computed over the raw body alone. The receiving endpoint recomputes the same HMAC and compares it in constant time.
Where This Breaks Down
Legacy HMAC schemes prove that whoever holds the shared secret produced the payload bytes. They leave real gaps around the rest of the request:
- Unprotected request metadata. Most legacy schemes sign only the body — not the URL, method, or headers. A signature computed over a payload doesn't say anything about which endpoint or HTTP method it was meant for.
- Canonicalization fragility. Because the HMAC depends on the exact raw bytes of the body, any proxy or framework that re-serializes, re-encodes, or reformats the payload before your handler sees it will silently break verification.
- Symmetric-key sprawl. HMAC requires both sides to hold the same secret. A company integrating dozens of vendors ends up storing dozens of long-lived shared secrets; a database compromise exposes all of them at once.
- No shared metadata format. Every vendor puts the timestamp somewhere different — inside the JSON, in a custom header as a Unix epoch, in an ISO-8601 string — so replay-window logic has to be reimplemented per integration. (Stripe is a partial exception: it bakes a timestamp into the signed string itself, which is one reason its scheme has aged reasonably well.)
2. Legacy HMAC vs. RFC 9421
RFC 9421 addresses these gaps by separating payload integrity from message signing, and by standardizing metadata using RFC 8941 Structured Field Values.
| Dimension | Legacy HMAC (Stripe, GitHub style) | RFC 9421 HTTP Message Signatures |
|---|---|---|
| Signed scope | Body only | Method, path, query, authority, selected headers, body digest — any combination |
| Payload integrity | Direct HMAC over raw body | Decoupled via a separate Content-Digest header (RFC 9530) |
| Cryptography | Symmetric HMAC only | Asymmetric (Ed25519, ECDSA P-256, RSA-PSS) or symmetric (HMAC) |
| Header format | Proprietary string per vendor | IETF Structured Fields (Signature-Input, Signature) |
| Replay protection | Ad hoc, vendor-specific | Standardized created / expires parameters |
| Key distribution | Custom, out-of-band | Not fully specified by RFC 9421 itself — see the note on Signature-Key below |
| Multiple signatures on one request | Not really supported | Native, via labeled signatures |
The one place the table above needs a caveat: RFC 9421 defines a keyid parameter but deliberately leaves how a verifier resolves that ID to an actual key up to the application. In practice this has meant JWKS endpoints where asymmetric keys are used, but that's a convention, not something the RFC mandates — a gap a newer draft is now trying to close (more on that below).
3. Anatomy of an RFC 9421 Webhook Request
Rather than signing the raw message verbatim, the sender selects specific "covered components," builds a canonical signature base string from them, and signs that string.
The Three Core Headers
POST /webhooks/v2/payment-events?account_id=acc_99812 HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
Content-Length: 98
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Signature-Input: sig1=("@method" "@target-uri" "content-type" "content-digest");created=1747461600;expires=1747461900;keyid="wh-key-2026-01";alg="ed25519"
Signature: sig1=:MEUCIQD0a823kL01x9...==:
{"event":"invoice.paid","amount":4900,"currency":"usd","customer":"cust_8820"}
Content-Digest (RFC 9530) carries the body's SHA-256 hash. Because it's a labeled field in its own right, a mutated body breaks the digest, which in turn breaks the signature — the two checks reinforce each other instead of being the same check.
Signature-Input declares, under a label (sig1, so multiple independent signatures can coexist), which components are covered and the signing metadata: created and expires timestamps, a keyid, and an alg identifier. Valid algorithm identifiers per the RFC's registry include ed25519, ecdsa-p256-sha256, rsa-pss-sha512, and hmac-sha256 — note that HMAC is still a first-class option here, so a team migrating from legacy HMAC doesn't have to adopt public-key crypto in the same step.
Signature holds the actual signature bytes, base64-encoded inside a Structured Field byte sequence.
Derived Components vs. HTTP Fields
Covered components fall into two classes. Derived components (prefixed with @) are computed from the request line and connection context: @method, @target-uri, @authority, @path, @query. HTTP field components are ordinary headers referenced by lowercased name, like content-type or content-digest.
The Signature Base
The exact string that gets signed, built from the request above:
"@method": POST
"@target-uri": https://api.yourcompany.com/webhooks/v2/payment-events?account_id=acc_99812
"content-type": application/json
"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"@signature-params": ("@method" "@target-uri" "content-type" "content-digest");created=1747461600;expires=1747461900;keyid="wh-key-2026-01";alg="ed25519"
Because @signature-params is itself the last line of what gets signed, an attacker can't quietly extend the expires window or swap the keyid without invalidating the whole signature.
4. Verifying an RFC 9421 Signature, Step by Step
- Payload digest check — recompute SHA-256 over the raw body and compare against
Content-Digest. - Temporal validation — reject if
now > expires, or ifnow - createdexceeds your clock-skew tolerance (commonly 300 seconds). - Key retrieval — resolve the
keyidfromSignature-Inputto an actual public key or shared secret. - Signature base reconstruction — rebuild the canonical string from the declared components, in order.
- Cryptographic verification — verify the
Signaturebytes against the reconstructed base using the resolved key.
A Minimal Node.js Implementation
The following is illustrative, not production code — it uses a hand-rolled regex parser where a real implementation should use a proper RFC 8941 Structured Fields parser (more on why below).
import crypto from 'node:crypto';
/**
* Verifies an RFC 9421 signed webhook request using Ed25519 asymmetric keys.
* Educational implementation — see section 5 for why you likely want a
* maintained library instead of hand-rolling this.
*
* @param {Object} req - Express-like request (headers, rawBody, method, protocol, originalUrl).
* @param {Map<string, string>} publicKeyMap - keyid -> PEM public key.
* @returns {boolean} true if valid; throws on any verification failure.
*/
export function verifyRfc9421Webhook(req, publicKeyMap) {
const signatureInputHeader = req.headers['signature-input'];
const signatureHeader = req.headers['signature'];
const contentDigestHeader = req.headers['content-digest'];
if (!signatureInputHeader || !signatureHeader || !contentDigestHeader) {
throw new Error('Missing required RFC 9421 / RFC 9530 signature headers.');
}
// Step 1: Verify Content-Digest (RFC 9530)
const computedHash = crypto.createHash('sha256').update(req.rawBody).digest('base64');
const expectedDigest = `sha-256=:${computedHash}:`;
if (!crypto.timingSafeEqual(Buffer.from(contentDigestHeader), Buffer.from(expectedDigest))) {
throw new Error('Payload integrity failure: Content-Digest mismatch.');
}
// Step 2: Parse Signature-Input (label: sig1)
const labelMatch = signatureInputHeader.match(/^([a-zA-Z0-9_-]+)=\((.*?)\);(.*)$/);
if (!labelMatch) throw new Error('Invalid Signature-Input format.');
const [, label, componentsRaw, paramsRaw] = labelMatch;
const coveredComponents = componentsRaw.split(' ').map(c => c.replace(/"/g, ''));
const params = {};
paramsRaw.split(';').forEach(param => {
const [key, val] = param.split('=');
params[key] = val.replace(/"/g, '');
});
const now = Math.floor(Date.now() / 1000);
if (params.expires && now > parseInt(params.expires, 10)) {
throw new Error('Signature expired.');
}
if (params.created && (now - parseInt(params.created, 10)) > 300) {
throw new Error('Signature creation timestamp exceeds clock tolerance window (300s).');
}
// Step 3: Fetch key
const publicKeyPem = publicKeyMap.get(params.keyid);
if (!publicKeyPem) throw new Error(`Unknown public key identifier: ${params.keyid}`);
// Step 4: Reconstruct the signature base
const targetUri = `${req.protocol}://${req.headers.host}${req.originalUrl}`;
const baseLines = [];
for (const comp of coveredComponents) {
let val;
switch (comp) {
case '@method': val = req.method.toUpperCase(); break;
case '@target-uri': val = targetUri; break;
case '@authority': val = req.headers.host.toLowerCase(); break;
case '@path': val = req.path; break;
default:
val = req.headers[comp.toLowerCase()];
if (!val) throw new Error(`Missing covered header component: ${comp}`);
}
baseLines.push(`"${comp}": ${val}`);
}
baseLines.push(`"@signature-params": (${componentsRaw});${paramsRaw}`);
const signatureBase = baseLines.join('\n');
// Step 5: Verify the signature
const sigValueMatch = signatureHeader.match(new RegExp(`${label}=:([^:]+):`));
if (!sigValueMatch) throw new Error('Malformed Signature header format.');
const signatureBuffer = Buffer.from(sigValueMatch[1], 'base64');
if (params.alg === 'ed25519') {
const isValid = crypto.verify(null, Buffer.from(signatureBase, 'utf-8'), publicKeyPem, signatureBuffer);
if (!isValid) throw new Error('Cryptographic signature verification failed.');
return true;
}
throw new Error(`Unsupported algorithm: ${params.alg}`);
}
5. Why Hand-Rolling This Is Harder Than It Looks
- Structured Field parsing.
Signature-InputandSignatureuse RFC 8941 grammar, not JSON or simple key-value pairs. Regex-based parsing (as in the example above) breaks on edge cases like folded headers or nested parameters — production implementations use a formal Structured Fields parser. - URL normalization traps.
@target-uriand@pathneed consistent handling of default ports, percent-encoding case, and proxy-forwarded headers (X-Forwarded-Host,X-Forwarded-Proto). A mismatch between how a reverse proxy reconstructs the URL and how your application code does it causes intermittent, hard-to-debug failures. - Key distribution isn't fully specified. RFC 9421 leaves resolving a
keyidto a key up to the application. This is real enough that a new IETF draft, draft-hardt-httpbis-signature-key (Cloudflare and Hellō, most recently updated July 2026), proposes a dedicatedSignature-Keyheader with multiple key-distribution schemes — inline keys, JWKS URIs, JWT-based delegation, and X.509 chains — specifically because the gap keeps causing incompatible implementations.
The upside: you increasingly don't have to write this by hand. There are now maintained RFC 9421 libraries across languages — http-message-signatures for Python, httpsig-rs and signet-http for Rust, NSign for .NET (with over 100,000 downloads of its core package as of late 2025), and a shared Go/TypeScript/Java/Swift/Kotlin implementation from zourzouvillys/httpsig validated against the RFC's own test vectors.
6. Where RFC 9421 Is Actually Being Used
This is the part most explainer articles skip. Real, verifiable adoption exists — it's just concentrated in newer, higher-trust use cases rather than mainstream SaaS webhooks:
- Visa's Trusted Agent Protocol (TAP), launched October 14, 2025 for authenticating AI shopping agents, signs requests using RFC 9421 rather than HMAC — covering method, path, body digest, a
createdtimestamp, and a nonce, verified against Visa's public-key directory. - Web Bot Auth, an approach for letting AI crawlers cryptographically identify themselves to publishers, uses RFC 9421 signatures with Ed25519 keys. The OpenBotAuth WordPress plugin is a working, publicly available implementation of this pattern.
- The Open Payments standard for payment interoperability requires RFC 9421 signing of every API request before a payment can be initiated — cited by developers building wallet software against it as a genuine (if sometimes painful) implementation requirement.
- Craft Cloud, the hosting platform from Craft CMS, uses RFC 9421 for request signing between trusted automated systems (CI/CD pipelines, static builds) and its infrastructure, specifically to avoid such traffic being mistaken for unsanctioned bots.
- Qerko, a payment platform, offers RFC 9421 as an option for its webhook interface — but tellingly, still ships legacy HMAC-SHA256 as a fallback "if RFC 9421 is difficult to implement in your stack."
- Federated protocols are transitioning gradually. The ActivityPub/Fediverse ecosystem (Mastodon and others) has historically used the older, never-standardized
draft-cavageHTTP signature scheme, and is actively discussing a move to RFC 9421 now that it's an actual RFC. Nextcloud's Open Cloud Mesh federation feature added RFC 9421 support in 2026 as a parallel "dual stack" alongside the legacy draft-cavage signatures it already supported, rather than a replacement — this reflects the reality that these things are typically added, not swapped in.
7. Where Legacy HMAC Still Rules
Set against that list, the biggest webhook senders by integration volume haven't moved:
- Stripe still documents and signs with
Stripe-Signature, an HMAC-SHA256 scheme, as its primary and only webhook signature mechanism as of 2026. - GitHub still signs with
X-Hub-Signature-256(HMAC-SHA256), withX-Hub-Signature(HMAC-SHA1) retained only for legacy compatibility. A community feature request to add public-key-based verification remains open and unanswered. - Popular security frameworks haven't added support yet. As noted above, Spring Security has no built-in RFC 9421 support as of early 2026, and its own tracking issue frames adoption as still emerging rather than mainstream.
- Voice and messaging platforms are actively noting the gap in their own docs — Sinch's Voice webhook documentation, for instance, explicitly states that RFC 9421 signing headers "are not yet supported" for its call webhooks and directs customers to Bearer-token authentication instead.
The honest summary: RFC 9421 is a real, well-designed, published standard that's gaining real traction in newer high-trust contexts — agentic payments, AI-crawler identity, federation protocols — precisely because those are green-field enough to design in from scratch. The long tail of existing SaaS webhook senders, including the biggest ones, hasn't had a reason to migrate away from schemes that already work, and nothing forces them to.
8. How Teams Handle Multi-Vendor Verification Today
Because every vendor still does things its own way, a common pattern is to push signature verification to a gateway or relay layer instead of writing it per-service. This category includes:
- Svix Ingest, which maintains built-in verification profiles for a long list of named providers (Stripe, GitHub, Shopify, DocuSign, Slack, and dozens more), plus a generic passthrough mode for providers it doesn't yet support.
- ngrok's Gateway, which offers a configurable
verify-webhookTraffic Policy action that validates an incoming signature against a known secret and can either block or log-and-pass on failure. - Dedicated webhook infrastructure vendors such as Hookdeck, Hooklistener, and InstaWebhook, which sit between senders and your application to absorb retries, timeouts, and — in most of these tools' current public documentation — legacy vendor-specific HMAC verification rather than RFC 9421 specifically.
Worth being precise about: based on each vendor's own current public documentation, this "gateway abstracts every scheme including RFC 9421" pattern is aspirational more than standard today. Most of these tools' documented verification support is built around the named legacy HMAC schemes vendors already use, not a generalized RFC 9421 verifier. If you're evaluating one of these for RFC 9421 specifically, check its docs for that support directly rather than assuming it's included.
9. Migration Checklist
If you're building or updating outbound webhook delivery infrastructure:
- Compute a body digest (RFC 9530). Use SHA-256 via
Content-Digestfor any outgoing request with a body. - Decide on symmetric vs. asymmetric. HMAC (
hmac-sha256) is a valid RFC 9421 algorithm and a reasonable first step if you're migrating from legacy HMAC without wanting to stand up key management yet. Move to Ed25519 or ECDSA P-256 when you're ready to drop shared secrets. - Cover the essentials. At minimum, sign
@method,@target-uri,content-type, andcontent-digest. - Set short-lived
created/expireswindows — 300 seconds is a common default. - Plan key distribution deliberately. RFC 9421 doesn't mandate a scheme; JWKS is the de facto convention today, and the emerging
Signature-Keydraft is worth watching if you need something more flexible. - Use a real Structured Fields parser, not hand-written regex, for
Signature-InputandSignature. - Keep your legacy HMAC scheme running in parallel for the foreseeable future — as the adoption picture above shows, most of your integration partners aren't going anywhere.
Summary
RFC 9421 is a genuinely better-designed answer to a real, long-standing problem: it binds signatures to the parts of a request that legacy HMAC schemes ignore, standardizes metadata that used to be reinvented per vendor, and supports both symmetric and asymmetric cryptography. But as of 2026, it's a standard that's winning in new, high-trust domains — agentic commerce, AI-agent identity, federated protocols — rather than one that has replaced the webhook schemes already in production at Stripe, GitHub, and most of the rest of the API economy. Teams building new systems have a good reason to design around RFC 9421 from day one; teams integrating with today's dominant webhook senders still need to keep their legacy HMAC verification code working right alongside it.
Sources: RFC 9421 (IETF) · RFC 9530 (IETF) · Stripe webhook docs · GitHub webhook validation docs · Spring Security issue #18502 · draft-hardt-httpbis-signature-key-07 · Nextcloud OCM PR #60136 · Qerko webhook docs · OpenBotAuth plugin