Blog

How to verify webhook signatures with HMAC

How webhook signatures work, how Stripe, GitHub and Slack sign their requests, and how to verify them safely in Node.js.

February 10, 2026

Your app has a URL that marks an order as paid when the right JSON arrives. Anyone on the internet can send JSON to a URL. That should make you slightly nervous.

That URL is a webhook endpoint. When something happens in another service, such as a Stripe payment succeeding, a push to GitHub or a message in Slack, the service sends an HTTP POST to it. Signature verification with HMAC is how you tell the real requests from the forged ones.

What a webhook signature is

The sender and you share a secret. For each request, the sender computes an HMAC of the request body with that secret and puts the result in a header. You compute the same HMAC over the body you received and compare. If they match, the request came from someone who knows the secret, and the body wasn't changed on the way.

Without this check, an attacker who knows your endpoint could fake a “payment succeeded” event, trigger actions in your system or feed it bad data. Every production webhook handler should verify signatures.

How HMAC works

HMAC (hash-based message authentication code, RFC 2104) combines a hash function such as SHA-256 with a secret key. The output proves both that the message is intact and that it was produced by someone with the key. In outline:

  1. The key is brought to the hash’s block size (64 bytes for SHA-256): hashed first if it's longer, then padded with zeros.
  2. The padded key is XORed (combined bit by bit) with the inner pad (0x36 repeated), the message is appended, and the result is hashed.
  3. The padded key is XORed with the outer pad (0x5c repeated), the inner hash is appended, and that is hashed again to give the HMAC.

The two nested hashes protect against length-extension attacks, where someone appends data to a signed message and computes a valid hash for it without knowing the secret. Those affect a plain hash(secret + message). You never need to implement this yourself: use crypto in Node.js, hmac in Python or crypto/hmac in Go.

What verification protects against

  • Forged requests. Someone posts a crafted event to your endpoint pretending to be Stripe or GitHub.
  • Tampered bodies. If the body is changed after signing, the HMAC no longer matches.
  • Replays. Someone resends a genuine request later. A signature alone doesn't stop this; schemes that sign a timestamp do.

Comparing signatures safely

Use a constant-time comparison rather than ===. A normal string comparison stops at the first different character, so it takes slightly longer the more leading characters match. By timing many attempts, an attacker could in principle guess a valid signature one character at a time. Network jitter makes this hard over the internet, but it's practical with low latency, for example from the same data center.

In Node.js, crypto.timingSafeEqual compares every byte regardless of where they differ. It throws if the two buffers have different lengths, so check the length first. The examples below use this helper:

safe-equal.js
const crypto = require("crypto");

function safeEqual(a, b) {
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

In Python use hmac.compare_digest(), and in Go subtle.ConstantTimeCompare() from crypto/subtle.

Stripe

Stripe sends a Stripe-Signature header with a timestamp and one or more signatures:

Text
t=1614556800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  1. Read t (a Unix timestamp, in seconds since 1 January 1970) and every v1 value. There can be more than one v1 while you're rotating secrets.
  2. Build the signed payload: the timestamp, a full stop, then the raw body, as in 1614556800.{...}.
  3. Compute its HMAC-SHA256 with your endpoint’s signing secret.
  4. Accept the request if any v1 value matches.
  5. Reject timestamps outside a tolerance window. Stripe’s libraries use 5 minutes by default.
JavaScript
function verifyStripeSignature(rawBody, header, secret) {
  const parts = header.split(",").map((p) => p.split("="));
  const timestamp = parts.find(([k]) => k === "t")?.[1];
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);

  if (!timestamp || signatures.length === 0) {
    throw new Error("Invalid signature header");
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) {
    throw new Error("Timestamp outside tolerance");
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  if (!signatures.some((sig) => safeEqual(expected, sig))) {
    throw new Error("Signature verification failed");
  }
  return true;
}

If you use Stripe’s official library, stripe.webhooks.constructEvent(rawBody, header, secret) does all of this for you.

GitHub

GitHub sends X-Hub-Signature-256: the HMAC-SHA256 of the raw body in hex, prefixed with sha256=.

Text
sha256=d57c68ca6f92289e6987922ff26938930f6e66a2d161ef06abdf1859230aa23c

There's no timestamp in GitHub’s scheme, so verification is just the HMAC and a comparison:

JavaScript
function verifyGitHubSignature(rawBody, signatureHeader, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

  if (!safeEqual(expected, signatureHeader)) {
    throw new Error("Signature verification failed");
  }
  return true;
}

GitHub also sends an older X-Hub-Signature header using SHA-1. Use the SHA-256 one.

Slack

Slack sends the timestamp in X-Slack-Request-Timestamp and the signature in X-Slack-Signature:

Text
v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503

The signed string is v0:, the timestamp, another colon and the raw body:

JavaScript
function verifySlackSignature(rawBody, timestamp, signature, secret) {
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) {
    throw new Error("Timestamp outside tolerance");
  }

  const expected =
    "v0=" +
    crypto
      .createHmac("sha256", secret)
      .update(`v0:${timestamp}:${rawBody}`)
      .digest("hex");

  if (!safeEqual(expected, signature)) {
    throw new Error("Signature verification failed");
  }
  return true;
}

Common mistakes

  • Verifying a parsed body. The signature covers the exact bytes sent. If middleware parses the JSON first and you re-serialize it, spacing or key order can change and the check fails. In Express, use express.raw({ type: "application/json" }) on the webhook route and verify req.body before parsing.
  • Changing the encoding. Compute the HMAC over the raw bytes as received, normally UTF-8.
  • Skipping the timestamp check. If the scheme signs a timestamp, enforce the tolerance, or old requests can be replayed.
  • Hardcoding the secret. Keep it in an environment variable or a secrets manager, and rotate it if it may have leaked.
  • Comparing with ===. Use a constant-time comparison.

A generic verifier

Header names and signed strings differ between services, but the core is always the same. Write it once and add a thin wrapper per service that reads the headers and builds the signed string:

JavaScript
function verifyHmacSignature({
  payload,
  secret,
  receivedSignature,
  algorithm = "sha256",
  encoding = "hex",
}) {
  const computed = crypto
    .createHmac(algorithm, secret)
    .update(payload)
    .digest(encoding);

  return safeEqual(computed, receivedSignature);
}

Testing your verification

To test, sign a known payload with a known secret using the same HMAC function, and send that signature in your test request:

JavaScript
const crypto = require("crypto");

const testSecret = "whsec_test123";
const testPayload = JSON.stringify({ event: "test", data: {} });
const testSignature = crypto
  .createHmac("sha256", testSecret)
  .update(testPayload)
  .digest("hex");

Cover a valid signature (accept), a wrong signature, a tampered body, an expired timestamp and a missing header (all reject). Give each rejection a clear log message; it saves time when an integration fails in production.

Checking a signature in the browser

The webhook signature generator and verifier computes HMAC-SHA256, HMAC-SHA384 or HMAC-SHA512 with the Web Crypto API, so nothing leaves your browser. In Generate mode, paste the payload and secret and get the signature in hex or Base64. In Verify mode, also paste the expected signature to see whether it matches; hex and Base64 are both accepted.

Paste exactly the string that was signed. For Stripe that is timestamp.body, for Slack v0:timestamp:body. Remove prefixes such as sha256= or v0= from the expected signature before checking it.

Need data from a webpage as JSON?

PulpMiner turns any public page into structured JSON and can save it as a live API. See it work on a demo page, no sign-up.

Try it on a demo page