# 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

```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

```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:

```js
// 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](/blog/retries-exponential-backoff).
- **Return [401](/http/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](/blog/why-not-sha256-for-passwords). To compute plain SHA-256 digests while debugging, use the [hash generator](/tools/security/hash-generator).
