For years, webhook security relied on provider-specific conventions. GitHub signs only the request body and sends it as X-Hub-Signature-256. Stripe signs the timestamp joined to the body and sends a hex digest in Stripe-Signature, while Standard Webhooks uses base64 digests prefixed v1,. These schemes work for simple point-to-point integrations. They become awkward when traffic crosses gateways, CDNs, and edge workers, and when the receiver is an AI agent rather than a deterministic handler. webhookrelay

Three shifts define modern webhook and ingress security:

  1. Standardized message signing. RFC 9421 (HTTP Message Signatures) lets a sender declare exactly which parts of a message are signed, using asymmetric keys if desired.
  2. Cryptographic agility without flag days. Providers can emit several signatures at once to move from HMAC to Ed25519, and eventually to post-quantum signatures, without breaking consumers.
  3. Agentic ingress. Webhook payloads that reach Model Context Protocol (MCP) servers and LLM agents carry attacker-controlled text. A valid signature proves who sent the request, not that the content is safe.

This guide covers patterns for platform engineers, security architects, and API providers. Where a pattern is my recommendation rather than a published standard, I say so. Hash values and signatures in examples are illustrative placeholders unless stated otherwise.

1. Migrating from Legacy Secrets to RFC 9421 HTTP Message Signatures

Where legacy webhook HMACs fall short

A traditional scheme computes an HMAC-SHA256 over a timestamp and the raw request body with a shared secret, then sends the result in a custom header:

POST /webhooks/payment HTTP/1.1 Host: api.consumer.example Content-Type: application/json X-Legacy-Signature: t=1794554880,v1=<hex-encoded HMAC-SHA256> 

This has four structural weaknesses:

  • Shared-secret blast radius. HMAC is symmetric, so provider and consumer hold the same key. If the consumer’s secret leaks, an attacker can forge webhooks that are indistinguishable from the provider’s.
  • Limited coverage. GitHub’s scheme signs only the body, and Stripe’s signs only the timestamp and body. The method, host, and path are not covered. If the same secret is reused across endpoints (for example staging and production), a captured delivery can be replayed against another endpoint. Per-endpoint secrets and timestamp tolerances reduce this risk, but they are conventions, not guarantees. webhookrelay
  • Fragile body hashing. Gateways, WAFs, and edge workers sometimes re-serialize JSON or alter bodies, which invalidates any hash over the raw bytes. Note that this also applies to RFC 9530 Content-Digest: it is a hash of the message content as transmitted, so a body rewritten in transit fails it too. The benefit of RFC 9421 is explicit coverage of headers and derived components, not immunity to body rewriting.
  • Fragmentation. Every provider invents its own header names, timestamp formats, and encodings. Standard Webhooks, an open specification, addresses this with webhook-id, webhook-timestamp, and webhook-signature headers, an HMAC-SHA256 scheme over the id, timestamp, and body, and a rotation mechanism. Its recommended timestamp tolerance is 300 seconds. webhookrelaysvix

What RFC 9421 provides

RFC 9421 was published by the IETF in February 2024. It defines how to sign and verify selected components of an HTTP message. The sender lists the covered components, which can be headers or derived components such as @method, @target-uri, @authority, and @path. It also attaches signature parameters: created, expires, nonce, keyid, alg, and tag. The signature covers a canonical “signature base” built from those components.

Two headers carry the result, both encoded as HTTP Structured Fields dictionaries:

  1. Signature-Input describes the covered components, the parameters, and the label for each signature.
  2. Signature maps each label to the signature bytes, encoded as a Structured Fields byte sequence (base64 between colons).

RFC 9421 normatively references RFC 8941 for Structured Fields. RFC 9651 has since superseded RFC 8941, and current parsers such as the structured-headers npm package support both.

RFC 9421 pairs naturally with RFC 9530 (Digest Fields). The Content-Digest header carries a digest of the body, and listing content-digest as a covered component binds the body to the signature.

Three clarifications keep expectations realistic:

  • RFC 9421 supports asymmetric algorithms (such as ed25519 and ecdsa-p256-sha256) and also hmac-sha256. Using the HMAC option brings back the shared-secret problem.
  • RFC 9421 does not define how verifiers discover keys. keyid is an opaque identifier, and you must choose a distribution mechanism: a JWKS URL, keys registered at subscription time, or the emerging Web Bot Auth directory convention.
  • A valid signature establishes message-layer integrity and authenticity for the covered components. It does not establish authorization or content safety.

Real-world adoption

RFC 9421 is increasingly used where parties don’t share a secret:

  • Web Bot Auth. This builds on RFC 9421 so bot operators can prove their identity to websites. An operator generates an Ed25519 key pair and publishes the public key at /.well-known/http-message-signatures-directory. Cloudflare’s documentation relies on IETF drafts for the directory and the protocol, and treats hosting the key directory at that path as a requirement. These are drafts, not final RFCs, so details may change. browserbasecloudflare
  • Agentic commerce. One published example is the Universal Commerce Protocol, whose order webhooks use RFC 9421 signatures with ECDSA P-256 and a Content-Digest header. ucpchecker
  • Tooling. curl’s maintainer reported merging experimental RFC 9421 support in July 2026. dev

Most large SaaS webhook providers still use HMAC conventions, so a consumer will need to support both formats for some time.

Anatomy of an RFC 9421 webhook request

The request below uses Content-Digest (SHA-256) and an Ed25519 signature. The digest shown is the one RFC 9530 uses for the example body {"hello": "world"}, and the signature is a placeholder.

POST /v1/webhooks/ingest HTTP/1.1 Host: webhooks.acme-corp.internal Date: Fri, 13 Nov 2026 07:28:00 GMT Content-Type: application/json Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: Signature-Input: sig1=("@method" "@target-uri" "host" "content-digest");created=1794554880;expires=1794555180;keyid="prov-ed25519-2026-11";alg="ed25519";nonce="8f9a2c4e-7b1d-4e3a-9c6f-1a2b3c4d5e6f" Signature: sig1=:BASE64_ED25519_SIGNATURE_PLACEHOLDER: 

Canonical signature base

Provider and consumer both build the same string from the components listed in Signature-Input. Each line is "component-name": value, and the final @signature-params line repeats the inner list and its parameters exactly as serialized:

"@method": POST "@target-uri": https://webhooks.acme-corp.internal/v1/webhooks/ingest "host": webhooks.acme-corp.internal "content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: "@signature-params": ("@method" "@target-uri" "host" "content-digest");created=1794554880;expires=1794555180;keyid="prov-ed25519-2026-11";alg="ed25519";nonce="8f9a2c4e-7b1d-4e3a-9c6f-1a2b3c4d5e6f" 

Choose components with intermediaries in mind. @target-uri and host break if a proxy rewrites the host or path before your verifier sees the request. Either verify against the public URL (as the code below does) or sign @authority and @path as Web Bot Auth examples do.

Practical migration roadmap

Moving an active ecosystem to RFC 9421 works best in phases that separate key distribution, body digesting, and enforcement.

Phase Provider milestone Consumer milestone Security outcome
1. Discovery & digesting Publish public keys (for example a JWKS endpoint, or a Web Bot Auth–style directory). Start emitting Content-Digest alongside legacy signatures. Cache keys, refreshing in the background on unknown keyid. Body integrity checked independently of the signature scheme.
2. Dual-header emission Emit legacy signature headers and RFC 9421 Signature-Input/Signature together. Run RFC 9421 verification in shadow mode: log failures but don’t reject. Reveals canonicalization problems caused by gateways and proxies without any outage.
3. Asymmetric enforcement Offer a per-tenant “strict” toggle that suppresses legacy headers. Switch to enforce mode: verify Content-Digest, check created/expires, check the nonce cache, verify the signature. Removes the shared-secret blast radius and prevents cross-endpoint replay.
4. Legacy deprecation Stop issuing symmetric secrets for new endpoints and publish a retirement schedule. Delete legacy secrets and custom HMAC code. A single, standards-based verification path.

Reference verification implementation (TypeScript / Node.js)

The sample below verifies a single Ed25519 signature. It takes care of several details that are easy to get wrong:

  • With structured-headers v2, byte sequences are returned as ArrayBuffer, not Uint8Array.
  • The set of covered components must be enforced. Otherwise an attacker can submit a validly signed message that covers nothing useful.
  • The nonce should be recorded only after the signature verifies, so unauthenticated requests can’t fill your replay cache.
  • Behind a proxy, request.url may be an internal URL. The sample reconstructs @target-uri from a configured public origin.
import { createHash, createPublicKey, timingSafeEqual, verify } from 'node:crypto'; import { parseDictionary, serializeList } from 'structured-headers'; interface VerificationResult { valid: boolean; reason?: string; keyId?: string; } type SfItem = [unknown, Map<string, unknown>]; type SfInnerList = [SfItem[], Map<string, unknown>]; const REQUIRED_COMPONENTS = ['@method', '@target-uri', 'content-digest']; const MAX_AGE_SECONDS = 300; const CLOCK_SKEW_SECONDS = 30; export async function verifyWebhookRfc9421( request: Request, rawBody: Uint8Array, opts: { publicOrigin: string; // e.g. "https://webhooks.acme-corp.internal" jwksResolver: (keyId: string) => Promise<JsonWebKey | null>; replayStore: { checkAndSetNonce: (key: string, ttlSeconds: number) => Promise<boolean> }; } ): Promise<VerificationResult> { // 1. Verify the RFC 9530 Content-Digest against the exact received bytes. const digestHeader = request.headers.get('content-digest'); if (!digestHeader) return { valid: false, reason: 'Missing Content-Digest header' }; let sha256: unknown; try { sha256 = parseDictionary(digestHeader).get('sha-256')?.[0]; } catch { return { valid: false, reason: 'Malformed Content-Digest header' }; } if (!(sha256 instanceof ArrayBuffer)) { return { valid: false, reason: 'Missing or invalid sha-256 digest' }; } const received = Buffer.from(sha256); const expected = createHash('sha256').update(rawBody).digest(); if (received.length !== expected.length || !timingSafeEqual(received, expected)) { return { valid: false, reason: 'Content-Digest mismatch: payload altered' }; } // 2. Parse the Signature-Input and Signature dictionaries. const sigInputHeader = request.headers.get('signature-input'); const sigHeader = request.headers.get('signature'); if (!sigInputHeader || !sigHeader) { return { valid: false, reason: 'Missing RFC 9421 signature headers' }; } let sigInputs: Map<string, unknown>; let signatures: Map<string, unknown>; try { sigInputs = parseDictionary(sigInputHeader) as Map<string, unknown>; signatures = parseDictionary(sigHeader) as Map<string, unknown>; } catch { return { valid: false, reason: 'Malformed signature headers' }; } const now = Math.floor(Date.now() / 1000); const url = new URL(request.url); const targetUri = new URL(url.pathname + url.search, opts.publicOrigin); let lastReason = 'No acceptable signature found'; // 3. Try each labelled signature we are able to verify. Never hardcode "sig1". for (const [label, rawInput] of sigInputs) { const sigEntry = signatures.get(label) as [unknown, Map<string, unknown>] | undefined; const inputEntry = rawInput as SfInnerList; if (!sigEntry || !Array.isArray(inputEntry[0])) continue; const [components, params] = inputEntry; const keyId = params.get('keyid'); const created = params.get('created'); const expires = params.get('expires'); const nonce = params.get('nonce'); if (params.get('alg') !== 'ed25519') continue; // advisory only; the key decides if (typeof keyId !== 'string' || typeof created !== 'number') { lastReason = 'Missing keyid or created'; continue; } // Covered components: no component parameters, and required ones present. const names = components.map(([n]) => String(n).toLowerCase()); if (components.some(([, p]) => p.size > 0)) { lastReason = 'Component parameters are not supported'; continue; } if (!REQUIRED_COMPONENTS.every((c) => names.includes(c))) { lastReason = 'Signature does not cover all required components'; continue; } // Temporal bounds. if ( created > now + CLOCK_SKEW_SECONDS || now - created > MAX_AGE_SECONDS || (typeof expires === 'number' && now > expires) ) { lastReason = 'Signature timestamp outside acceptable validity window'; continue; } // 4. Rebuild the signature base in the order the sender declared. const lines: string[] = []; let missing: string | null = null; for (const name of names) { if (name === '@method') { lines.push(`"@method": ${request.method.toUpperCase()}`); } else if (name === '@target-uri') { lines.push(`"@target-uri": ${targetUri.href}`); } else if (name === 'host') { lines.push(`"host": ${targetUri.host}`); } else { const value = request.headers.get(name); if (value === null) { missing = name; break; } lines.push(`"${name}": ${value.trim()}`); } } if (missing) { lastReason = `Covered header '${missing}' missing from request`; continue; } // Re-serialize this label's inner list + parameters for @signature-params. const sigParams = serializeList([inputEntry] as Parameters<typeof serializeList>[0]); lines.push(`"@signature-params": ${sigParams}`); const signatureBase = Buffer.from(lines.join('\n'), 'utf8'); // 5. Resolve the key and verify the Ed25519 signature. const jwk = await opts.jwksResolver(keyId); if (!jwk) { lastReason = `Unknown keyid: ${keyId}`; continue; } const sigBytes = sigEntry[0]; if (!(sigBytes instanceof ArrayBuffer)) { lastReason = 'Invalid signature encoding'; continue; } const ok = verify(null, signatureBase, createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(sigBytes)); if (!ok) { lastReason = 'Cryptographic verification failed'; continue; } // 6. Only now consume the nonce (replay protection). if (typeof nonce === 'string') { const fresh = await opts.replayStore.checkAndSetNonce(`${keyId}:${nonce}`, MAX_AGE_SECONDS + CLOCK_SKEW_SECONDS); if (!fresh) return { valid: false, reason: 'Replay detected: nonce already consumed', keyId }; } return { valid: true, keyId }; } return { valid: false, reason: lastReason }; } 

Two production notes. First, if you accept requests without a nonce, replay protection relies on the created window alone. Second, deduplicate deliveries by an event identifier in your application as well, since providers retry.

2. Handling Dual-Active Webhook Signatures Without Downtime

Why you can’t do a flag-day cutover

Moving a high-throughput webhook platform from HMAC-SHA256 to Ed25519, or preparing for NIST-standardized post-quantum signatures such as ML-DSA (FIPS 204), cannot be a synchronized switch. Consumers have different release cycles. An abrupt change causes verification failures, retry storms, and growing dead-letter queues. The approach is dual-active signature emission combined with consumer-side policy.

This idea is already common in the legacy world. The Standard Webhooks specification supports both HMAC-SHA256 (v1) and Ed25519 (v1a), and when a sender uses several signers the signatures are space-separated in one header. It also defines a list of signatures so that receivers can try each one during secret rotation. githubbird

Multiple signatures in RFC 9421

Signature-Input and Signature are both Structured Fields dictionaries, so one request can carry several labelled signatures made with different algorithms and keys, without new headers.

The example below is a design sketch for a migration from Ed25519 to a post-quantum scheme. The alg value ml-dsa-65 is hypothetical. As of mid-2026, an IETF draft notes that RFC 9421’s “HTTP Signature Algorithms” registry contained no post-quantum algorithm when it was written (May 2026). Check the IANA registry before relying on any identifier, and agree on identifiers bilaterally if you must run ahead of it. ietf

POST /webhooks/events HTTP/1.1 Host: consumer.example.com Content-Digest: sha-256=:uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=: X-Legacy-HMAC-SHA256: t=1794554880,v1=<hex-encoded HMAC-SHA256> Signature-Input: sig-ed25519=("@method" "@targe...
Source:https://dev.to/instawebhook/emerging-standards-security-architecture-in-2026-rfc-9421-dual-active-signature-migrations-and-3o1h