HookSteadBETA

Webhook security: HMAC signatures, timestamps and replay protection

Why every webhook endpoint must verify signatures, how HMAC verification works step by step, and the mistakes that quietly break it.

8 min read · Updated

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

  1. 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.
  2. 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.
  3. Compute the expected signature with your secret over the same signed content.
  4. Compare in constant time, with crypto.timingSafeEqual in Node, hmac.compare_digest in Python or hmac.Equal in Go. A normal string comparison leaks how many characters matched.
  5. Accept any of the listed signatures. During secret rotation the header carries more than one; one match is enough.
  6. Only then parse the JSON and act on it.
ts
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?