All posts
June 6, 20266 min read

How to verify webhook signatures with HMAC

A webhook is just an HTTP endpoint you expose to the internet so a provider can push events to you. The problem is that the endpoint is public. Once someone discovers the URL, nothing stops them from sending their own POST requests pretending to be the provider. If your handler trusts the body blindly, an attacker can fake events: a fake payment, a fake subscription upgrade, a fake account deletion. You need a way to confirm that each request genuinely came from the provider and was not altered in transit.

How providers sign payloads

The standard solution is a shared secret combined with HMAC. When you register a webhook, the provider gives you a signing secret that only the two of you know. For every event, the provider computes an HMAC over the raw request body using that secret, then sends the result in a header. Stripe uses Stripe-Signature, GitHub uses X-Hub-Signature-256, and many others follow the same idea with their own header name.

HMAC is a keyed hash. It takes the message and the secret and produces a fixed length digest, almost always with SHA-256 today. Without the secret you cannot produce a matching digest, and changing even one byte of the body changes the output completely. So a valid signature proves two things at once: the sender knew the secret, and the body was not modified.

Recompute and compare

On your side, verification is straightforward. Take the exact raw bytes of the request body, compute the HMAC with the same secret and algorithm, and check it against the header.

import crypto from 'node:crypto'

function verify(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)      // raw bytes, not parsed JSON
    .digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(signatureHeader)

  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}

A few details matter more than they look.

  • Sign the raw body, not the parsed object. If your framework parses JSON and you re-serialize it, key order and whitespace can change, and the digest will not match. Capture the original bytes before any parsing.
  • Match the algorithm and encoding. Confirm whether the provider sends the digest as hex or base64, and whether the header includes a scheme prefix like sha256= that you need to strip first.
  • Use a constant-time comparison. Comparing strings with === can leak how many leading characters matched through tiny timing differences, which a patient attacker can exploit byte by byte. crypto.timingSafeEqual compares in constant time regardless of where the first difference is.

Tolerate timestamps to stop replays

A valid signature alone does not prevent replay. If someone captures a real signed request, they can send it again later and it will still verify. Providers defend against this by including a timestamp in what they sign, usually in the same header. Your handler reads the timestamp, rejects anything older than a small window such as five minutes, and only then checks the signature. Because the timestamp is part of the signed data, an attacker cannot move it forward without breaking the signature.

const tolerance = 5 * 60 // seconds
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
  throw new Error('timestamp outside tolerance window')
}

Test it before you trust it

When a verification fails and you cannot tell why, recompute the digest by hand against the exact body and secret. The HMAC Generator does this entirely in your browser, so you can paste the payload and secret and see the expected SHA-256 output without sending either to a server. To inspect what a provider actually delivers, including the raw body and signature header, capture a live request with the Webhook Inspector. Comparing the value you compute against the value you receive almost always reveals the mismatch: a stripped prefix, the wrong encoding, or a body that was parsed before it was hashed.