Verifying webhook signatures
Three signature formats, the raw body, timestamps, and comparing safely.
A webhook URL is public. Anyone who finds it can send it a request that looks like a payment notification or an order update. Senders deal with this by signing each webhook with a secret that only they and the receiver know, and the receiver checks the signature before trusting anything in the body. This lesson verifies real signatures in the three formats that cover most providers, then shows the mistakes that make correct signatures fail.
Signed samples to practise on
The webhook testing API serves sample events in the formats used by Stripe, GitHub and Shopify, signed with the published test secret whsec_apibee_test_secret. The payloads are sample data made for testing, not real events from those companies. Each comes back exactly as a sender would deliver it, body and signature headers:
$ curl -i https://api.testingapis.com/api/webhooks/samples/github
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature-256: sha256=fa0176693e0fbf3a50fbdde6039875593019d842a76bdc6bf74ec5bac6ca5899
{"ref":"refs/heads/main","before":"6113728f…","after":"76ae82c7…","repository":{…},"pusher":{…},"commits":[…]}The three formats differ in what they sign and how they write the result:
| Format | Header | Signed message | Encoding |
|---|---|---|---|
| GitHub style | X-Hub-Signature-256: sha256=… | The raw body | Hex, with a sha256= prefix |
| Shopify style | X-Shopify-Hmac-Sha256 | The raw body | Base64 |
| Stripe style | Stripe-Signature: t=…,v1=… | The timestamp, a dot, then the raw body | Hex, with the timestamp alongside |
All three are HMAC-SHA256 with the shared secret. The Stripe style adds a timestamp to the signed message, so a receiver can refuse old deliveries. The request signing lesson explains HMAC itself.
Verifying them
// verify.mjs: check the three signed sample webhooks, then tampered versions of them
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = "whsec_apibee_test_secret";
// Constant-time comparison; timingSafeEqual throws if the lengths differ, so check that first
function safeEqual(a, b) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
// GitHub style: X-Hub-Signature-256: sha256=<hex HMAC of the raw body>
function verifyGithub(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return safeEqual(expected, header ?? "");
}
// Shopify style: X-Shopify-Hmac-Sha256: <base64 HMAC of the raw body>
function verifyShopify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("base64");
return safeEqual(expected, header ?? "");
}
// Stripe style: Stripe-Signature: t=<unix seconds>,v1=<hex HMAC of "t.body">, plus a freshness check
function verifyStripe(rawBody, header, secret, { toleranceSeconds = 300, now = Date.now() / 1000 } = {}) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return { ok: false, reason: "malformed header" };
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
if (!safeEqual(expected, parts.v1)) return { ok: false, reason: "signature mismatch" };
if (Math.abs(now - Number(parts.t)) > toleranceSeconds) return { ok: false, reason: "timestamp too old" };
return { ok: true };
}And the checks, against the live samples and changed copies of them:
const get = async (provider) => {
const res = await fetch(`https://api.testingapis.com/api/webhooks/samples/${provider}`);
return { body: await res.text(), headers: res.headers };
};
const gh = await get("github");
const sig = gh.headers.get("x-hub-signature-256");
console.log("github, as received: ", verifyGithub(gh.body, sig, SECRET));
console.log("github, one character changed: ", verifyGithub(gh.body.replace("main", "mainx"), sig, SECRET));
console.log("github, wrong secret: ", verifyGithub(gh.body, sig, "whsec_guess"));
console.log("github, re-serialised JSON: ", verifyGithub(JSON.stringify(JSON.parse(gh.body)), sig, SECRET));
const sh = await get("shopify");
console.log("shopify, as received: ", verifyShopify(sh.body, sh.headers.get("x-shopify-hmac-sha256"), SECRET));
console.log("shopify, re-serialised JSON: ", verifyShopify(JSON.stringify(JSON.parse(sh.body)), sh.headers.get("x-shopify-hmac-sha256"), SECRET));
const st = await get("stripe");
const stripeSig = st.headers.get("stripe-signature");
console.log("stripe header:", stripeSig);
console.log("stripe, 5-minute tolerance: ", verifyStripe(st.body, stripeSig, SECRET));
console.log("stripe, as if received at t: ", verifyStripe(st.body, stripeSig, SECRET, { now: 1721300000 + 10 }));
console.log("stripe, body changed: ", verifyStripe(st.body.replace("2000", "20000"), stripeSig, SECRET, { now: 1721300000 + 10 }));github, as received: true
github, one character changed: false
github, wrong secret: false
github, re-serialised JSON: true
shopify, as received: true
shopify, re-serialised JSON: false
stripe header: t=1721300000,v1=5d908542d42efef84bcb7b3bb17390d3576b862e5f1b678c6ecf691fb65e9cf6
stripe, 5-minute tolerance: { ok: false, reason: 'timestamp too old' }
stripe, as if received at t: { ok: true }
stripe, body changed: { ok: false, reason: 'signature mismatch' }Each sample verifies as received. A single changed character, or the wrong secret, fails. The rest of the output is the interesting part.
Always verify the raw body
The signature covers the exact bytes that were sent. Many web frameworks parse JSON bodies automatically, and the tempting fix is to turn the parsed object back into a string and verify that. Sometimes that works by luck, as it did for the GitHub sample, which happens to be compact JSON that comes out identical. The Shopify sample shows why it can't be relied on:
raw body: {"id":820982911946154508,"email":"ava.thompson@example.com",...
re-encoded: {"id":820982911946154500,"email":"ava.thompson@example.com",...The order ID, 820982911946154508, is larger than JavaScript can hold exactly in a number, so parsing rounds it to 820982911946154500. Same length, different digits, and the signature fails. Spacing, key order and how characters are escaped can all change on a round trip too. Configure the receiver to keep the raw body for webhook routes, and verify that before parsing anything. In Express, for example, that means a raw-body parser on the webhook route instead of the JSON one.
Timestamps and old deliveries
The Stripe-style sample is signed with a fixed timestamp, t=1721300000, from 2024, so that its signature never changes. A verifier that enforces the usual five-minute tolerance correctly rejects it as too old. Checked as if it had arrived ten seconds after it was signed, it passes, and a changed body still fails. The freshness check is what stops someone who captured a genuine webhook from replaying it later, so keep it in production and test both sides of it: a delivery inside the window and one just outside.
Comparing signatures safely
Use a constant-time comparison like timingSafeEqual, never ===. An ordinary comparison stops at the first character that differs, and with enough attempts the timing difference can reveal a valid signature one character at a time. Note that timingSafeEqual throws when its two inputs differ in length, which a forged header easily does; the safeEqual helper checks the length first so a bad header is a clean false, not a crash.
What to test on a webhook receiver
- A correctly signed delivery is accepted.
- Missing signature header, malformed header and wrong secret are all refused, with 401 or 400, not 500.
- One changed byte in the body is refused.
- A body with unusual spacing, key order or large numbers, correctly signed, is accepted, which proves the raw body is used.
- For timestamped formats, a delivery older than the tolerance is refused.
- Nothing in the body is acted on before the signature is checked.
Exercise
Fetch the GitHub-style sample with ?format=envelope, which returns the body, headers and secret as JSON. Write a test that parses the body string and pretty-prints it with JSON.stringify(JSON.parse(body), null, 2), verifies that, and expects failure. Then add a Stripe-style check for a timestamp exactly 300 seconds before now and one 301 seconds before. Which passes, and does your verifier agree with the boundary you intended?