All articles

how-to

How to Verify Webhook Signatures (HMAC) the Right Way

How HMAC webhook signatures work, the raw-body trap that breaks verification, constant-time comparison, replay protection, and copy-paste code for Stripe, GitHub, Shopify and Slack.

How to Verify Webhook Signatures (HMAC) the Right Way

Your webhook endpoint is a public URL — anyone on the internet can POST to it. So before you trust a webhook, you must prove two things: it really came from the provider, and it wasn't modified in transit. Providers make this possible by signing every payload with a shared secret using HMAC. Your job is to recompute that signature and compare.

It sounds simple, and the happy path is. But there are three ways to get it subtly wrong that cause hours of pain: the raw-body trap, non-constant-time comparison, and missing replay protection. This guide covers all three, with correct code for the major providers.


How HMAC webhook signatures work

HMAC (Hash-based Message Authentication Code) takes a secret key and a message and produces a fixed-length digest. Anyone with the secret can compute it; anyone without it cannot forge it.

The flow is the same almost everywhere:

  1. The provider computes HMAC-SHA256(secret, raw_body) (sometimes over timestamp + body).
  2. It sends the digest in a header.
  3. You recompute HMAC-SHA256(secret, raw_body) and compare to the header — in constant time.

If they match, the request is authentic and untampered. If not, reject it with 401.


Trap #1: verify the RAW body, not parsed JSON

This is the bug behind ~80% of "signature mismatch" reports.

HMAC is computed over exact bytes. If your web framework parses the JSON body and you then re-serialize it to verify, the bytes change — key order, whitespace, unicode escaping all differ — and the digest will never match, even with the correct secret.

Broken (global JSON parser destroys the raw bytes):

app.use(express.json());            // body is now a JS object
app.post("/webhook", (req, res) => {
  const body = JSON.stringify(req.body); // re-serialized — DIFFERENT bytes!
  // HMAC over this will never match
});

Correct (capture raw bytes on the webhook route):

app.post(
  "/webhook",
  express.raw({ type: "application/json" }), // req.body is a Buffer of raw bytes
  (req, res) => {
    const raw = req.body; // verify against THIS
  }
);

Trap #2: use a constant-time comparison

Comparing strings with === or == short-circuits at the first differing byte. The tiny timing difference leaks information an attacker can use to forge a signature byte-by-byte. Always use a constant-time comparison.

import crypto from "crypto";

function safeEqual(a, b) {
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  // timingSafeEqual throws if lengths differ — guard first
  return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}

Trap #3: protect against replay

A captured-but-valid request can be replayed by an attacker. Providers that include a timestamp in the signed material (Stripe, Slack) let you reject anything too old:

const FIVE_MIN = 5 * 60 * 1000;
if (Math.abs(Date.now() - timestamp * 1000) > FIVE_MIN) {
  return res.status(401).send("timestamp too old");
}

Combine this with idempotency (key off the event ID) so even an in-window duplicate is processed only once.


Copy-paste verification by provider

GitHub — X-Hub-Signature-256

import crypto from "crypto";

function verifyGitHub(rawBody, header, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return (
    header.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected))
  );
}

Stripe — Stripe-Signature (timestamp + body)

Use the official library — it does the timestamped HMAC and replay window for you:

const event = stripe.webhooks.constructEvent(
  rawBody,            // raw Buffer
  req.headers["stripe-signature"],
  process.env.STRIPE_WEBHOOK_SECRET
); // throws on mismatch or stale timestamp

Or by hand, the signed message is ${timestamp}.${rawBody} and you HMAC-SHA256 that.

Shopify — X-Shopify-Hmac-Sha256 (base64)

function verifyShopify(rawBody, header, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("base64"); // note: base64, not hex
  return (
    header.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected))
  );
}

Slack — X-Slack-Signature (v0:timestamp:body)

function verifySlack(rawBody, sigHeader, tsHeader, secret) {
  const base = `v0:${tsHeader}:${rawBody}`;
  const expected =
    "v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
  return (
    sigHeader.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))
  );
}

Debugging a signature mismatch fast

When verification fails, you need to see the exact bytes and headers the provider sent — not what your framework handed you after parsing. That's where a capture tool earns its keep:

npx @anonymilyhq/cli listen 3000

Anonymily shows the raw body and signature header as received, lets you replay the exact request while you fix the verifier, and can re-sign a payload you modified so you can test edge cases:

npx @anonymilyhq/cli replay <hookId> <requestId> --resign

And if you still can't spot the cause, the AI diagnosis compares the received signature against what your secret would produce and tells you precisely which trap you hit — raw body, wrong secret, wrong encoding, or stale timestamp.


Checklist

  • ☐ Verify against the raw request body bytes.
  • ☐ Use the right encoding (hex vs base64) and header name per provider.
  • ☐ Compare in constant time (crypto.timingSafeEqual).
  • ☐ Reject stale timestamps where the provider signs them.
  • ☐ Make handlers idempotent on event ID.
  • ☐ Return 401 on mismatch, 2xx only after a successful check.

Next steps


TL;DR

Recompute HMAC-SHA256(secret, raw_body) over the raw bytes, compare in constant time, reject stale timestamps, and use the provider's exact header and encoding. When the math won't line up, capture and replay the real request to see exactly why:

npx @anonymilyhq/cli listen 3000

Try it in 30 seconds

Capture your first webhook — from any provider — with one command. No account required.

npx @anonymilyhq/cli listen 3000Open Dashboard →