HMAC How Webhooks Know You’re Not Lying
Every time GitHub, Stripe, or Shopify fires a webhook at your server, they sign the payload with HMAC. Here's exactly how it works — and how to verify it in your own code.
You receive a POST request claiming to be from Stripe. The payload says a payment just succeeded. You process the order, ship the goods — and three days later you discover the request was faked. Ouch.
This is why every serious webhook provider signs their payloads using HMAC. If you understand HMAC, you understand why GitHub, Stripe, Shopify, Twilio, and practically every modern API uses it — and you’ll know exactly how to verify those signatures in your own server code.
What HMAC actually is
HMAC stands for Hash-based Message Authentication Code. It answers one question: “Did the person who sent me this message know the shared secret?”
It works by running a cryptographic hash function — usually SHA-256 — over the combination of a secret key and the message body. The output is a fixed-length string that:
- Changes completely if even one byte of the message changes
- Cannot be produced without knowing the secret key
- Cannot be reversed to reveal the key or the original message
The formula is compact: HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message)). You don’t need to memorise the internals — every language ships a standard library implementation — but it helps to know the intent: the key is mixed into the hash twice, in different ways, to prevent a class of attacks called length-extension attacks.
How a webhook provider uses it
When you register a webhook endpoint, the provider gives you a signing secret — a random string only you and they know. When an event fires:
- The provider serialises the event payload to JSON (or a canonical string).
- It computes
HMAC-SHA256(secret, payload). - It sends the request with the signature in a header —
X-Hub-Signature-256for GitHub,Stripe-Signaturefor Stripe, and so on.
On your end, you do the same computation over the raw request body and compare. If they match, the payload is authentic. If they don’t, discard it.
Verifying in code
Here’s what verification looks like across the most common languages. The pattern is identical in every one: compute, compare with a constant-time function.
Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody must be a Buffer or string — NOT parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Python
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
PHP
function verifyWebhook(string $rawBody, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
The critical detail: use the raw body
The most common implementation mistake is passing a parsed and re-serialised payload to the HMAC function instead of the original raw bytes. Key ordering, whitespace, and Unicode escaping all affect the hash. The provider signed the exact bytes it sent over the wire — you must hash exactly those same bytes.
In Express (Node.js), that means configuring the body parser to preserve the raw buffer:
app.use('/webhooks', express.raw({ type: 'application/json' }));
In Django, use request.body rather than request.data. In Flask, use request.get_data().
Why not just a plain hash?
A plain SHA-256 of the payload proves nothing — anyone can compute SHA256(payload) without a secret. HMAC’s secret key is what makes it an authentication code, not just a checksum. It answers “who sent this” rather than just “was this corrupted in transit”.
Why not asymmetric signatures (like RSA)?
RSA and ECDSA let the receiver verify a signature without knowing the private key — that’s valuable for public broadcast (like code-signing). For webhooks, there are only two parties who ever need to verify the signature: you and the provider. A shared secret is simpler, faster, and equally secure in that model. Some providers (Svix, Clerk) do offer asymmetric webhook signing for cases where you can’t safely store a secret server-side.
Replay attacks — and how to stop them
A valid HMAC signature proves authenticity but not freshness. An attacker who captures a legitimate signed request can replay it later. Stripe counters this by including a timestamp in the Stripe-Signature header and hashing the timestamp alongside the payload body. On your side, you reject any request where the timestamp is more than five minutes old.
If you’re building your own webhook system, do the same: include a monotonically increasing nonce or a Unix timestamp in the signed message, and reject stale requests server-side.
Timing-safe comparison is not optional
Never compare HMAC signatures with a plain equality check (===, ==). Short-circuit string comparison leaks information about how many leading bytes match — an attacker making thousands of requests can reconstruct the expected signature one byte at a time. Always use a constant-time comparison:
- Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - PHP:
hash_equals() - Go:
hmac.Equal() - Ruby:
ActiveSupport::SecurityUtils.secure_compare()
Putting it together: a production-ready webhook handler
Here’s a complete example using GitHub’s X-Hub-Signature-256 header in Node.js:
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
// Keep the body as raw bytes — critical!
app.use('/github/webhook', express.raw({ type: 'application/json' }));
app.post('/github/webhook', (req, res) => {
const sigHeader = req.headers['x-hub-signature-256'];
if (!sigHeader) return res.status(401).send('Missing signature');
const sig = sigHeader.replace('sha256=', '');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Safe to process the event now
console.log('Event type:', req.headers['x-github-event']);
res.sendStatus(200);
});
app.listen(3000);
Quick reference: who uses what
| Provider | Header | Algorithm | Timestamp in signature? |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | No |
| Stripe | Stripe-Signature | HMAC-SHA256 | Yes |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | No |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | No |
| Slack | X-Slack-Signature | HMAC-SHA256 | Yes |
| Paddle | Paddle-Signature | HMAC-SHA256 | Yes |
Install Our Extensions
Add IO tools to your favorite browser for instant access and faster searching
恵 Scoreboard Has Arrived!
Scoreboard is a fun way to keep track of your games, all data is stored in your browser. More features are coming soon!
Must-Try Tools
View All New Arrivals
View AllUpdate: Our latest tool was added on Apr 27, 2026
