Verify a webhook
Check that a request really came from the provider that claims to have sent it.
#The shape of it
A provider signs the request body with a secret you both hold and puts the signature in a header. You recompute the MAC over the raw body bytes and compare.
import { hmacVerify } from "unsecure/hmac";
import { randomJitter } from "unsecure/random";
export async function handleWebhook(request: Request) {
const body = await request.text(); // raw text, before any JSON.parse
const signature = request.headers.get("x-signature");
const valid = await hmacVerify(process.env.WEBHOOK_SECRET, body, signature);
await randomJitter(10, 50);
if (!valid) return new Response("Forbidden", { status: 403 });
return process(JSON.parse(body));
}hmacVerify compares in constant time and returns false for a missing header, a signature that is not canonical hex, or a value that is not text at all. There is nothing to catch here.
#Signature formats
Providers wrap the signature differently. Strip the wrapper yourself, then tell hmacVerify how to read what is left.
// "sha256=<hex>"
const header = request.headers.get("x-hub-signature-256") ?? "";
const signature = header.startsWith("sha256=") ? header.slice(7) : header;
await hmacVerify(secret, body, signature); // hex is the default
// A bare base64 signature
await hmacVerify(secret, body, header, { returnAs: "base64" });
// SHA-512, base64url
await hmacVerify(secret, body, header, { algorithm: "SHA-512", returnAs: "base64url" });returnAs names the format hmac() would have produced. Get it wrong and the check returns false rather than throwing, so test the happy path once.
#Signing over more than the body
Many providers sign a constructed string, usually to bind a timestamp into the signature. Build the same string on your side.
const timestamp = request.headers.get("x-timestamp") ?? "";
const signedPayload = `${timestamp}.${body}`;
// Reject old requests before checking the signature: a valid signature on a
// replayed request is still a replay
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return new Response("Stale", { status: 400 });
const valid = await hmacVerify(secret, signedPayload, signature);#Importing the key once
A busy endpoint imports the same secret into Web Crypto on every request. Do it once instead.
import { hmacVerify, importHmacKey } from "unsecure/hmac";
const key = await importHmacKey(process.env.WEBHOOK_SECRET);
export async function handleWebhook(request: Request) {
const body = await request.text();
const valid = await hmacVerify(key, body, request.headers.get("x-signature"));
// …
}The key is non-extractable and sign-only, and its hash is fixed at import time.
#Rotating the secret
Accept either secret during the overlap, and check both every time so the timing does not say which one matched.
const [oldValid, newValid] = await Promise.all([
hmacVerify(previousKey, body, signature),
hmacVerify(currentKey, body, signature),
]);
if (!(oldValid || newValid)) return new Response("Forbidden", { status: 403 });#What to get right
- Verify the raw bytes.
JSON.stringify(JSON.parse(body))is not guaranteed to reproduce what the sender signed. Read the body as text first, and parse only after the check passes. - Do not compare with
===. That is whatsecureCompareexists for, andhmacVerifyuses it internally. - Handle the empty secret separately. An unset
WEBHOOK_SECRETthrowsOUT_OF_RANGE, and it should page you rather than return a 403.
import { UnsecureError } from "unsecure/errors";
try {
const valid = await hmacVerify(secret, body, signature);
if (!valid) return new Response("Forbidden", { status: 403 });
} catch (error) {
if (error instanceof UnsecureError && error.code === "OUT_OF_RANGE") {
logger.error("WEBHOOK_SECRET is not configured");
return new Response("Server error", { status: 500 });
}
throw error;
}