Why webhooks need signatures
A webhook endpoint is a public URL. Anyone who finds it can send it a request that looks exactly like a real invoice.paid event. If your app trusts the body, an attacker can unlock a paid plan for free.
The fix is a signature: the sender and your app share a secret, the sender signs each request with it, and your app rejects anything whose signature doesn't match.
How HMAC signing works
Most providers use HMAC-SHA256. The sender computes:
signature = HMAC_SHA256(secret, signed_content)where signed_content is the raw body, usually with an id and a timestamp in front of it. The Standard Webhooks specification, which HookStead uses for its deliveries, signs:
{webhook-id}.{webhook-timestamp}.{raw body}and sends three headers: webhook-id, webhook-timestamp and webhook-signature (for example v1,K5oZ…).
Verifying a signature, step by step
- Read the raw body exactly as received, before any JSON parsing. Parsing and re-serializing changes whitespace and key order, and the signature no longer matches.
- Check the timestamp. Reject requests more than about five minutes old or in the future. This blocks replay attacks, where someone resends a captured request later.
- Compute the expected signature with your secret over the same signed content.
- Compare in constant time, with
crypto.timingSafeEqualin Node,hmac.compare_digestin Python orhmac.Equalin Go. A normal string comparison leaks how many characters matched. - Accept any of the listed signatures. During secret rotation the header carries more than one; one match is enough.
- Only then parse the JSON and act on it.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, h: Headers, secret: Buffer): boolean {
const id = h.get("webhook-id"), ts = h.get("webhook-timestamp");
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${id}.${ts}.${rawBody}`).digest();
// secret: the bytes after "whsec_", base64-decoded
return (h.get("webhook-signature") ?? "").split(" ").some((part) => {
if (!part.startsWith("v1,")) return false;
const sig = Buffer.from(part.slice(3), "base64");
return sig.length === expected.length && timingSafeEqual(sig, expected);
});
}Tested receivers for Node, Python and Go are on the Verify signatures page.
Mistakes that quietly break verification
- Framework body parsing. Express, Next.js and others parse JSON before your handler runs. Read the raw body for webhook routes.
- Signing the wrong secret format. Standard Webhooks secrets start with
whsec_and the rest is base64; decode it before using it as the HMAC key. - Skipping the timestamp check, which turns a valid signature into a reusable pass.
- Logging secrets or full payloads. Webhook bodies often hold personal data; log ids, not contents.
- Returning detailed errors. Answer a bad signature with a plain 400 or 401 and log the reason on your side.
Beyond signatures
- Serve the endpoint over HTTPS only.
- Rotate secrets with an overlap window, so old and new signatures are both accepted while you deploy.
- Don't rely on IP allowlists alone. Providers change ranges, and allowlists don't prove the body wasn't changed.
How HookStead helps
HookStead checks the provider's signature (Stripe, GitHub, Shopify and others) on arrival and rejects forgeries before they are stored. Your endpoint then verifies only one thing: HookStead's Standard Webhooks signature, which is freshly computed on every attempt. Provider signatures expire after a few minutes, which breaks retries. Secrets can be rotated with an overlap window from the dashboard.
Read next: Retries, idempotency and failures, or start with What is a webhook?