A webhook receiver is a public URL. Anything that learns it can send a request that looks like yours, so the receiver has to check who sent a message and that nobody changed it in transit. There are two families of answer: a shared secret with an HMAC, or a public-key signature checked against a key the sender publishes.

RFC 9421, HTTP Message Signatures, standardises both for HTTP. Web Bot Auth is a newer IETF effort that profiles RFC 9421 for automated clients. This post covers what each specifies, what applying them to webhooks takes, and, in the last section, what Lumbox signs its own webhooks with today.

What RFC 9421 signs

RFC 9421 was published in February 2024 as a Proposed Standard. It signs a list of named components that the signer picks, not the request as a blob, so a signature can survive a proxy that rewrites unrelated headers.

Components come in two kinds. Derived components start with @ and describe parts of the request line: @method, @target-uri, @authority, @path, @query, and @status for responses. Anything else is an HTTP field named in lowercase, such as content-digest or content-type.

Each signature also carries parameters: created and expires as Unix timestamps, nonce as a random per-signature value, alg, keyid to say which key verifies it, and tag, an application label that tells a verifier which protocol the signature belongs to.

All of this travels in two headers. Signature-Input maps a label such as sig1 to the ordered component list plus its parameters, and Signature maps the same label to the signature bytes, base64 encoded between colons. The RFC registers six algorithm names: rsa-pss-sha512, rsa-v1_5-sha256, hmac-sha256, ecdsa-p256-sha256, ecdsa-p384-sha384 and ed25519.

The signature base

Signer and verifier each build the same string, the signature base. Every covered component becomes one line of the form "name": value, in the order listed, and the final line is always "@signature-params" followed by the exact value from Signature-Input. If the verifier rebuilds a different string, verification fails.

The body is not covered unless you add it

RFC 9421 has no component for the message body. To protect a webhook payload, the sender adds a Content-Digest header (RFC 9530) holding a SHA-256 of the body and covers that header. Section 7.2.8 of the RFC names the trap: the verifier must also recompute the digest from the bytes it actually received. A receiver that checks only the signature will accept a swapped body as long as the attacker leaves the original Content-Digest header in place.

A signed webhook, worked through

Here is a webhook POST signed with Ed25519 and profiled the way Web Bot Auth asks (next section). The key id is the example thumbprint from the Web Bot Auth draft, and the digest is the real SHA-256 of the body shown.

POST /hooks/email HTTP/1.1
Host: agents.example.com
Content-Type: application/json
Content-Digest: sha-256=:MFCnKlvwwOnSHXsqbQRJtNyDH6qlUip6hB3eUTFYwXY=:
Signature-Agent: sig1="https://sender.example"
Signature-Input: sig1=("@method" "@target-uri" "content-digest" "signature-agent";key="sig1");created=1790676843;expires=1790677143;nonce="b3k2pQx9Lr";keyid="NFcWBst6DXG-N35nHdzMrioWntdzNZghQSkjHNMMSjw";tag="web-bot-auth"
Signature: sig1=:<64-byte Ed25519 signature, base64>:

{"event":"email.received","timestamp":"2026-09-29T10:14:03.512Z","data":{"email_id":"eml_4kQ9xZr2LmT7vB1nW8yH","inbox_id":"inb_7Hn2Kp9QwX4rT1mZ6cVb"}}

The signature base that the sender signed, and that the receiver rebuilds, is:

"@method": POST
"@target-uri": https://agents.example.com/hooks/email
"content-digest": sha-256=:MFCnKlvwwOnSHXsqbQRJtNyDH6qlUip6hB3eUTFYwXY=:
"signature-agent";key="sig1": "https://sender.example"
"@signature-params": ("@method" "@target-uri" "content-digest" "signature-agent";key="sig1");created=1790676843;expires=1790677143;nonce="b3k2pQx9Lr";keyid="NFcWBst6DXG-N35nHdzMrioWntdzNZghQSkjHNMMSjw";tag="web-bot-auth"

This signature is valid for five minutes after created, and the nonce lets the receiver refuse a second copy of the same signed request inside that window.

What Web Bot Auth adds

Web Bot Auth comes from the IETF webbotauth working group. Its protocol document, draft-ietf-webbotauth-httpsig-protocol-00, is dated 1 September 2026, has authors from Cloudflare and Google, and is still an Internet-Draft that can change before it becomes an RFC.

It targets bots calling websites they have no prior relationship with, and narrows RFC 9421 for that case:

  • A signature must cover @authority or @target-uri, and must carry created, expires, keyid and tag="web-bot-auth". The draft recommends an expiry of no more than 24 hours.
  • keyid is the base64url JWK SHA-256 thumbprint of the key, so it is derived from the key material instead of chosen by the operator.
  • A signed request carries a Signature-Agent header naming an HTTPS URL where the keys live, and the signature must cover that header.
  • By default the keys are a JWK Set at /.well-known/http-message-signatures-directory on that origin, served as application/http-message-signatures-directory+json.
  • Keys rotate by publishing the new key next to the old one and removing the old one after it expires. The draft defines no revocation mechanism.
  • Shared-secret HMAC, which RFC 9421 allows, is forbidden by the profile.

The profile covers no body by default; an agent that needs body integrity must send and cover Content-Digest, which a webhook always does.

Verifying one on the receiving side

Parse the headers with a maintained RFC 9421 library, since Signature-Input is a Structured Field with awkward edge cases. The security comes from the checks around the library:

  1. Reject the request if tag is not web-bot-auth, if created is more than a few seconds in the future, or if expires has passed.
  2. Compare the covered Signature-Agent member with the sender origin you expect, and refuse anything else. An attacker's key verifies an attacker's signature perfectly; RFC 9421 calls this key specification mixup, and the draft requires key lookup by the pair of URL and key, never by key id alone.
  3. Fetch the directory from that pinned origin, cache it, and select the key whose kid equals keyid.
  4. Rebuild the signature base, verify it, and check the digest and nonce. With Node's built-in crypto, Ed25519 verification takes null as the algorithm:
import { createHash, verify } from "node:crypto";

const sigOk = verify(null, Buffer.from(signatureBase), { key: jwk, format: "jwk" }, Buffer.from(sigB64, "base64"));
const digestOk = contentDigest === `sha-256=:${createHash("sha256").update(rawBody).digest("base64")}:`;
const accept = sigOk && digestOk && !seenNonces.has(nonce);

Because verifiers fetch directories named in a header, the draft lists SSRF precautions: cap response size and key count, time out fetches, limit redirects, and block private and loopback addresses. Pinning the sender origin in step 2 means your receiver only ever fetches one directory.

HMAC or signatures for webhooks

For webhooks the choice is closer. The receiver registered its URL and got a secret in that step, so the missing prior relationship that motivates Web Bot Auth does not exist.

HMAC-SHA256, shared secretRFC 9421 with Ed25519
Key distributionSecret handed over once per endpointPublic keys fetched from a directory
Who can forge a messageThe sender, and anyone holding the receiver's copy of the secretOnly the private key holder
RotationCoordinated with every receiverNew key published beside the old one
Body integrityCovers the raw body directlyNeeds Content-Digest plus a digest check
Freshness and replayWhatever the payload carriescreated, expires, nonce
Receiver codeA few lines of standard libraryStructured Field parsing, key cache, directory fetch

Public-key signatures pay for their extra code when many receivers verify one sender, or when a gateway or auditor must verify messages without being able to create them.

What Lumbox sends today

Lumbox signs its webhooks with HMAC-SHA256. Production deliveries carry no Signature-Input, Signature or Signature-Agent headers, so a receiver that requires them will reject every Lumbox delivery.

Each webhook you register with POST /v1/webhooks gets its own secret starting with whsec_, returned once in the create response. Every delivery is a JSON POST with the event name in X-Lumbox-Event and a hex HMAC-SHA256 of the raw body, keyed with that secret, in X-Lumbox-Signature:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyLumbox(rawBody, signatureHex, secret) {
  if (typeof signatureHex !== "string" || !/^[0-9a-f]{64}$/.test(signatureHex)) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(signatureHex, "hex"));
}

The compare runs in constant time, and the format check comes first because timingSafeEqual throws when the two buffers differ in length. Compute the HMAC over the bytes exactly as they arrived, before any JSON parsing, since re-serialising a parsed object changes them.

There is no separate timestamp header. The body carries a timestamp field, and because the HMAC covers the whole body, a receiver can trust that field and refuse stale deliveries. For replays, deduplicate on data.email_id. Each delivery is attempted once with a 10-second timeout and is not retried, so treat a webhook as a notification that might not arrive and reconcile on a schedule with GET /v1/inboxes/:id/emails?since=.

The webhooks docs have the registration call, and the webhook workflow guide builds a complete receiver around these checks.