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
- You and the provider share a secret, usually shown once in the provider's dashboard.
- The provider computes
HMAC-SHA256(secret, body)and sends it in a header. - 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.