devkult_
tools29converters34
home/blog/verify-webhook-signatures-hmac
securityapiwebhooks

How to Verify Webhook Signatures with HMAC-SHA256

A webhook endpoint is a public URL that anyone can POST to. Signatures are how you know a request really came from the service you expect — and wasn't changed on the way. Almost every provider uses the same scheme: an HMAC of the request body, keyed with a secret only you and the provider know.

How it works

  1. You and the provider share a secret, usually shown once in the provider's dashboard.
  2. The provider computes HMAC-SHA256(secret, body) and sends it in a header.
  3. You compute the same HMAC over the body you received and compare. Same value means same secret and same bytes.

A plain SHA-256 hash wouldn't work: anyone could recompute it for a forged body. The secret key is what makes an HMAC unforgeable.

Node.js

import crypto from "node:crypto";

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? "");
  // timingSafeEqual throws on different lengths, so check first
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The sha256= prefix matches GitHub's X-Hub-Signature-256 header; adapt the format to your provider.

Python

import hashlib
import hmac

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")

The three mistakes that break verification

1. Hashing re-serialized JSON instead of the raw body. If your framework parses the JSON and you call JSON.stringify(req.body), key order, spacing and number formatting can change — and the HMAC won't match. Sign exactly the bytes you received:

// Express: keep the raw bytes for this route
app.post("/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySignature(req.body, req.get("X-Hub-Signature-256"), process.env.WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // ...handle the event
  res.status(204).end();
});

In a Next.js route handler, read the body with await request.text() before parsing it.

2. Comparing with ===. A normal string comparison stops at the first differing character, so response times leak how much of a guessed signature was right. Use crypto.timingSafeEqual in Node or hmac.compare_digest in Python.

3. Accepting old requests. A valid signed request captured once can be replayed forever unless the signature covers a timestamp. Stripe, for example, signs timestamp + "." + body and sends Stripe-Signature: t=1700000000,v1=…. Reject requests whose timestamp is more than a few minutes old.

Handling webhooks well

  • Respond fast. Return 2xx as soon as the signature checks out and the event is stored; do slow work in a background job. Providers time out and retry slow endpoints.
  • Expect duplicates. Retries mean the same event can arrive twice. Store event IDs and skip ones you've processed — the same idea as idempotency keys.
  • Return 401 or 400 for bad signatures and log them; a burst usually means a misconfigured secret, not an attack.
  • Rotate secrets if one leaks; most providers let you run two at once during the switch.

HMAC is for verifying messages, not storing passwords — for that, see why not SHA-256 for passwords. To compute plain SHA-256 digests while debugging, use the hash generator.

More from the blog