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 what secureCompare exists for, and hmacVerify uses it internally.
  • Handle the empty secret separately. An unset WEBHOOK_SECRET throws OUT_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;
}