Skip to content

Verifying webhook signatures

Quorum signs deliveries exactly as the Standard Webhooks specification describes, so any compliant library works (standardwebhooks on npm, PyPI, and others; pass it the whsec_... secret). To do it by hand:

  1. Read the raw request body, byte for byte. Do not parse and re-serialise it first.
  2. Take webhook-id, webhook-timestamp and webhook-signature from the headers. Reject the request if the timestamp is more than 5 minutes from your clock; that stops replays.
  3. The signed content is {webhook-id}.{webhook-timestamp}.{body}.
  4. The key is the secret without its whsec_ prefix, base64-decoded.
  5. The signature is the base64 of the HMAC-SHA256 of the signed content.
  6. The header holds one or more v1,{signature} entries separated by spaces (two while a secret is being rotated). Compare each with a constant-time comparison; accept if any matches.

The specification’s own example, which every sample below passes:

secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
id msg_p5jXN8AQM9LWM0D4loKWxJek
timestamp 1614265330
body {"test": 2432232314}
signature v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(
secret,
headers,
rawBody,
{ toleranceSeconds = 300, now = Date.now() } = {},
) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}

With Express, keep the raw body for the route (express.raw({ type: "application/json" })) and call verify(process.env.QUORUM_WEBHOOK_SECRET, req.headers, req.body.toString("utf8")). Return 401 when it is false and 204 quickly when it is true.

  • Parsing first. Re-serialising JSON changes whitespace and key order; sign over the raw bytes.
  • Using the secret as text. The key is the base64-decoded secret without whsec_.
  • Comparing with ==. Use a constant-time comparison.
  • Ignoring the timestamp. Without the 5-minute check a captured request can be replayed.
  • Only knowing one secret during a rotation. Either secret may sign; the header carries both, so one match is enough.
  • Treating delivery as exactly once. De-duplicate on webhook-id.