Verify a webhook
Accept a webhook only when its signature proves the provider sent it, and answer every forged, stale or garbled delivery with the same 401.
An HMAC (hash-based message authentication code) is a stamp you can only make if you know a secret. Your provider stamps every body it sends and puts the stamp in a header. You stamp the body that arrived and compare. Same stamp means the sender does hold the secret and nobody touched the body on the way. Concepts explains why that comparison has to take the same time whatever it finds.
#Before you start
- The signing secret from your provider's dashboard, in an environment variable. This page calls it
WEBHOOK_SECRET. - The request body exactly as it arrived. A framework that parses JSON for you, such as Express with
express.json(), throws those bytes away: give the webhook route a raw body instead. - Your provider's signature format: the header, the string it signs, and the encoding. The table further down lists five common ones.
- A valid signature does not stop someone from sending a captured request again. You can refuse those only when the provider signs a timestamp too.
#Verify a signed body
Here's a handler for GitHub-style deliveries, where the X-Hub-Signature-256 header holds sha256= followed by the hex HMAC-SHA256 of the body.
import { hmacVerify, importHmacKey } from "unsecure/hmac";
import { randomJitter } from "unsecure/random";
import { textDecoder } from "unsecure/utils";
const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("WEBHOOK_SECRET is not set");
const key = await importHmacKey(secret);
/** Every failed check answers alike, after a random pause. */
async function refuse(): Promise<Response> {
await randomJitter(10, 50);
return new Response("Invalid signature", { status: 401 });
}
export async function handleWebhook(request: Request): Promise<Response> {
// The signature covers the exact bytes the provider sent
const body = new Uint8Array(await request.arrayBuffer());
const header = request.headers.get("x-hub-signature-256");
const signature = header?.startsWith("sha256=") ? header.slice(7) : null;
if (!(await hmacVerify(key, body, signature))) return refuse();
// The body is now trusted: parse it and queue the event
return new Response(null, { status: 204 });
}Each part of the handler guards against one way a delivery can go wrong:
- Start-up check: without
WEBHOOK_SECRETthe module won't load, so a forgotten variable stops the deploy instead of breaking the first delivery.importHmacKeythen runs once, not on every request. - Raw bytes: the stamp covers the exact bytes the provider sent, and
arrayBuffer()keeps them that way. Turning them into text or JSON first can change them, so parse only afterhmacVerifyreturnstrue. - Untrusted header: a missing header, a value that isn't hex, or a signature of the wrong length all come back as
false. There's nothing to catch. Concepts covers why. - One refusal: every failed check goes through
refuse(), which waits a random 10 to 49 ms and sends the same 401. Someone probing your endpoint can't tell which check tripped, and the pause blurs timing differences in the rest of your handler.
#Match your provider's format
Every provider makes its stamp a little differently: another header, another string, another encoding. Strip any prefix yourself, rebuild the string they signed, and tell returnAs how the stamp is written:
| Provider | Signature header | Signed string | returnAs |
|---|---|---|---|
| GitHub | X-Hub-Signature-256, sha256= + hex | body | "hex" (default) |
| Shopify | X-Shopify-Hmac-SHA256, base64 | body | "base64" |
| Stripe | Stripe-Signature, t=…,v1= + hex | t value, ., body | "hex" |
| Slack | X-Slack-Signature, v0= + hex | v0:, X-Slack-Request-Timestamp, :, body | "hex" |
| Standard Webhooks | webhook-signature, v1, + base64 | webhook-id, ., webhook-timestamp, ., body | "base64" |
Each row links to the provider's own page, so check there before you rely on it. The next handler follows the Standard Webhooks specification, which signs a timestamp too:
import { base64Parse, textDecoder, textEncoder } from "unsecure/utils";
// "whsec_" + base64: the key is the decoded bytes, not the string
const secret = process.env.STANDARD_WEBHOOK_SECRET;
if (!secret) throw new Error("STANDARD_WEBHOOK_SECRET is not set");
const key = await importHmacKey(base64Parse(secret.replace(/^whsec_/, ""), { returnAs: "bytes" }));
export async function handleStandardWebhook(request: Request): Promise<Response> {
const body = new Uint8Array(await request.arrayBuffer());
const id = request.headers.get("webhook-id");
const timestamp = request.headers.get("webhook-timestamp");
// A captured request replays with a valid signature: refuse old ones too
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!id || !timestamp || !(age <= 5 * 60)) return refuse();
// Signed content: "<id>.<timestamp>." followed by the body bytes
const prefix = textEncoder.encode(`${id}.${timestamp}.`);
const signed = new Uint8Array(prefix.length + body.length);
signed.set(prefix);
signed.set(body, prefix.length);
// One "v1,<base64>" entry per secret the provider signs with
const entries = request.headers.get("webhook-signature")?.split(" ") ?? [];
const results = await Promise.all(
entries.map(
async (entry) =>
entry.startsWith("v1,") && hmacVerify(key, signed, entry.slice(3), { returnAs: "base64" }),
),
);
if (!results.includes(true)) return refuse();
// Parse and queue the event, as above
return new Response(null, { status: 204 });
}What changes compared with the GitHub handler:
- Decoded key: a Standard Webhooks secret looks like
whsec_followed by base64, and the real key is the bytes that base64 decodes to. Import the string as it is and every delivery fails. - Timestamp window: a delivery more than five minutes away from your clock is refused before any HMAC runs. Without it, a request someone recorded would verify forever. It gets the same 401 as a bad signature.
- Several signatures: while a provider rotates its secret, the header carries one stamp per secret. The handler checks all of them, and one match is enough.
- Signed bytes: the id, the timestamp and the dots become bytes glued in front of the raw body. The body itself stays untouched until the check passes.
#When it fails
Unless a row says otherwise, every refusal below is the same 401 with the body Invalid signature:
| Situation | What happens |
|---|---|
| Header missing, prefix missing, or not hex or base64 | hmacVerify gets null or text it cannot decode and returns false. |
| Body changed, or parsed and re-serialized before the check | false. If every genuine delivery fails, check that you verify the raw body. |
Wrong returnAs or hash for your provider | false on every request. Test one real delivery before you go live. |
| Standard Webhooks timestamp outside five minutes | Refused before any HMAC runs. If fresh deliveries get it, check the server clock. |
WEBHOOK_SECRET unset or empty | The start-up check throws WEBHOOK_SECRET is not set. Without it, importHmacKey throws INVALID_TYPE for an unset variable and OUT_OF_RANGE for an empty one. |
STANDARD_WEBHOOK_SECRET is not whsec_ plus base64 | base64Parse throws MALFORMED at start-up. See Handle errors. |
#Going further
A few things the handlers above leave out:
- Rotating your own secret: import both keys and accept a match on either. Run both checks every time with
Promise.all, so the timing doesn't give away which secret matched. - Other hashes: if your provider signs with SHA-512, import with
importHmacKey(secret, { algorithm: "SHA-512" }). The key remembers its hash, and verifying with a differentalgorithmthrowsOUT_OF_RANGE. - Duplicate deliveries: providers retry when your endpoint is slow to answer, so the same event can show up twice, correctly signed both times. Keep the delivery ids you've handled and skip repeats.
- Sending webhooks: to be the provider yourself, stamp each outgoing body with
hmac(key, body). See Sign and verify messages.