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.

verify-webhook.ts
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_SECRET the module won't load, so a forgotten variable stops the deploy instead of breaking the first delivery. importHmacKey then 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 after hmacVerify returns true.
  • 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:

ProviderSignature headerSigned stringreturnAs
GitHubX-Hub-Signature-256, sha256= + hexbody"hex" (default)
ShopifyX-Shopify-Hmac-SHA256, base64body"base64"
StripeStripe-Signature, t=…,v1= + hext value, ., body"hex"
SlackX-Slack-Signature, v0= + hexv0:, X-Slack-Request-Timestamp, :, body"hex"
Standard Webhookswebhook-signature, v1, + base64webhook-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:

verify-standard-webhook.ts
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:

SituationWhat happens
Header missing, prefix missing, or not hex or base64hmacVerify gets null or text it cannot decode and returns false.
Body changed, or parsed and re-serialized before the checkfalse. If every genuine delivery fails, check that you verify the raw body.
Wrong returnAs or hash for your providerfalse on every request. Test one real delivery before you go live.
Standard Webhooks timestamp outside five minutesRefused before any HMAC runs. If fresh deliveries get it, check the server clock.
WEBHOOK_SECRET unset or emptyThe 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 base64base64Parse 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 different algorithm throws OUT_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.