Signing Webhooks with HMAC-SHA256
Sign outbound webhooks on Workers using Web Crypto, no node:crypto required
Overview
When a Worker sends an outbound webhook, the receiver needs a way to confirm the
request really came from you and was not tampered with in transit. The standard
approach is a shared secret and an HMAC signature: you compute
HMAC-SHA256(body, secret) and send the hex digest in a header. The consumer
recomputes the same HMAC over the body it received and compares.
On Cloudflare Workers there is no node:crypto. Everything here uses the Web
Crypto API (crypto.subtle), which is available in the Workers runtime by
default.
The Signature Scheme
This pattern, drawn from a production notifications Worker, uses:
- Header:
X-Notify-Signature: sha256=<hex> - Body: the raw UTF-8 JSON payload bytes
- Key: a per-recipient
webhook_secret(UTF-8 bytes) - Algorithm: HMAC-SHA256 via
crypto.subtle.sign
The sha256= prefix is a widely used convention (GitHub webhooks use the same
shape). It names the algorithm inline so the digest format is self-describing
and leaves room to rotate to a different scheme later.
Computing the Signature
/**
* Compute HMAC-SHA256 over `body` using `secret`, returned as lowercase hex.
* Uses Web Crypto subtle.sign — no Node.js dependencies.
*/
export async function computeHmacHex(body: string, secret: string): Promise<string> {
const keyBytes = new TextEncoder().encode(secret);
const bodyBytes = new TextEncoder().encode(body);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBuffer = await crypto.subtle.sign("HMAC", cryptoKey, bodyBytes);
const sigBytes = new Uint8Array(sigBuffer);
return Array.from(sigBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
A few details worth noting:
importKey("raw", …)imports the secret as raw key material. The algorithm descriptor{ name: "HMAC", hash: "SHA-256" }selects HMAC with SHA-256.- The
falseargument marks the key as non-extractable, and["sign"]limits it to signing only. crypto.subtle.signreturns anArrayBuffer; converting each byte to a zero-padded two-character hex string withpadStart(2, "0")produces the lowercase hex digest.
Sending the Webhook
export interface WebhookPayload {
id: string;
sourcePath: string;
sourceKind: string;
sourceItemId: string;
payloadSummary: string;
firedAt: string; // ISO 8601
channels: string[];
}
/**
* POST to the webhook URL with HMAC signature header.
* Throws on non-2xx or network error.
* Caller is responsible for applying timeouts.
*/
export async function sendWebhook(
webhookUrl: string,
webhookSecret: string,
payload: WebhookPayload,
): Promise<void> {
const body = JSON.stringify(payload);
const hmacHex = await computeHmacHex(body, webhookSecret);
const res = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Notify-Signature": `sha256=${hmacHex}`,
},
body,
});
if (!res.ok) {
throw new Error(`Webhook delivery error ${res.status}: ${webhookUrl}`);
}
}
Notice that body is computed once with JSON.stringify and used for both
the signature and the request body. This is not an incidental detail — it is the
whole correctness argument, covered next.
Sign Over Exactly the Bytes the Consumer Sees
The consumer verifies by recomputing HMAC-SHA256(rawBody, secret) over the raw
request body it received and comparing to your digest. For that comparison to
succeed, you must sign over exactly the same bytes the consumer will read.
This breaks the moment the body is re-serialized. If the consumer parses the JSON and then re-stringifies it before hashing, any difference in key order, whitespace, or number formatting produces a different byte sequence and therefore a different digest — the signature appears invalid even though the secret is correct.
⚠️ Hash the raw body, not a re-parsed object
On both sides, run HMAC over the literal request body string. Sign the exact
string you put in the fetch body; verify the exact string you read from the
request — never JSON.parse then JSON.stringify and hash the result. The
sender controls serialization once, and the consumer must treat that byte
sequence as opaque.
Verifying on the Consumer Side
The receiver runs the same Web Crypto incantation, then recomputes the digest
over the raw body and compares. computeHmacHex is reused unchanged — only the
direction differs.
/**
* Verify an incoming webhook request.
* Reads the RAW body once and hashes exactly those bytes.
*/
export async function verifyWebhook(request: Request, secret: string): Promise<boolean> {
const header = request.headers.get("X-Notify-Signature");
if (!header?.startsWith("sha256=")) {
return false;
}
const received = header.slice("sha256=".length);
// Read the raw body string and hash exactly these bytes.
const rawBody = await request.text();
const expected = await computeHmacHex(rawBody, secret);
return received.length === expected.length && received === expected;
}
💡 Constant-time comparison
For a hardened implementation, compare the two hex digests in constant time so a mismatch does not leak position information through timing. The length check above plus a direct string compare is adequate for many internal services; reach for a constant-time compare when the endpoint is publicly exposed.
Related: Web Crypto on Workers
This is the signing direction of the same toolkit the auth recipe uses on the
verification side. Auth with Pages Functions covers token
handling under the same “Web Crypto, no node:crypto” theme — both rely on
crypto.subtle rather than a Node crypto library, just pointed in opposite
directions: here we produce a signature, there we validate a caller.