Webhook HMAC Verification in Node.js and Python: A Secure Implementation Guide
Step-by-step guide to implementing HMAC-SHA256 webhook signature verification in Node.js and Python, covering raw body parsing, timing-safe comparison, and replay attack prevention.
An open webhook endpoint is an open door. If you are accepting inbound HTTP callbacks from a payment processor, a shipping provider, or any third-party service, and you are not verifying the request signature, anyone who knows your endpoint URL can send fabricated events. A fake payment.succeeded event is not a theoretical attack — it is one HTTP POST away.
HMAC signature verification is the standard defence, and it takes about 20 lines of code to do correctly. Here is how.
How HMAC Webhook Signatures Work
When you register a webhook with a provider, they give you a secret — a shared key known only to them and you. When they send an event, they compute an HMAC-SHA256 hash of the request body using that secret and include the hash in a request header. You receive the request, compute the same hash independently, and compare the two. If they match, the request is authentic.
The header name varies by provider:
- Stripe:
Stripe-Signature(also includes a timestamp for replay protection) - Shopify:
X-Shopify-Hmac-SHA256(base64-encoded) - GitHub:
X-Hub-Signature-256(prefixed withsha256=) - Twilio:
X-Twilio-Signature
The algorithm is always HMAC-SHA256 across modern providers.
The Critical Rule: Use the Raw Body
Before any code, understand this: you must verify the signature against the raw, unmodified request body bytes. If you parse the JSON first and then re-serialize it to verify, byte-level differences (whitespace, key ordering) will break the comparison.
In web frameworks, body parsers typically consume the raw bytes and replace them with a parsed object. You need to intercept the raw bytes before that happens.
Implementation in Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
// Use raw body parser for the webhook route ONLY
app.post(
'/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'];
if (!signature) {
return res.status(400).send('Missing signature header');
}
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
// Timing-safe comparison — never use === for signatures
const sigBuffer = Buffer.from(signature);
const expBuffer = Buffer.from(expected);
if (
sigBuffer.length !== expBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expBuffer)
) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// process event...
res.status(200).send('OK');
}
);Key points: express.raw() gives you req.body as a Buffer instead of a parsed object. crypto.timingSafeEqual prevents timing attacks where an attacker could infer the correct signature one byte at a time by measuring response times.
Implementation in Python (FastAPI)
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException, Header
app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()
@app.post("/webhooks/shopify")
async def shopify_webhook(
request: Request,
x_shopify_hmac_sha256: str = Header(...)
):
raw_body = await request.body()
expected = hmac.new(
WEBHOOK_SECRET,
raw_body,
hashlib.sha256
).digest()
import base64
expected_b64 = base64.b64encode(expected).decode()
# Timing-safe comparison
if not hmac.compare_digest(expected_b64, x_shopify_hmac_sha256):
raise HTTPException(status_code=401, detail="Invalid signature")
# parse and process
import json
event = json.loads(raw_body)
# process event...
return {"status": "ok"}hmac.compare_digest in Python provides the same constant-time comparison as crypto.timingSafeEqual in Node.js. Always use it instead of ==.
Replay Attack Prevention
An attacker who intercepts a valid, signed request could replay it later. Stripe includes a timestamp in its Stripe-Signature header for exactly this reason. You should reject requests with a timestamp older than 5 minutes:
Stripe-Signature: t=1714000000,v1=abc123...
Parse the t= value, compare it to the current Unix timestamp, and return 400 if the difference exceeds 300 seconds. Do this check before the HMAC comparison so you fail fast.
Secret Rotation Without Downtime
Most providers support two active secrets simultaneously during rotation. The procedure:
- Generate a new secret in the provider's dashboard.
- Update your code to accept signatures from either the old or the new secret.
- Update the provider to use the new secret.
- Verify traffic is flowing with the new secret.
- Remove the old secret from your code and the provider's dashboard.
Never rotate secrets by simply swapping them — you will have a window where valid deliveries fail.
What Can Go Wrong
- Comparing parsed JSON instead of raw bytes — signatures will never match.
- Using string equality (
===) instead of a timing-safe function — timing oracle vulnerability. - Storing the secret in source code or committing it to git — rotate immediately if this happens.
- Skipping timestamp validation — replay attacks become trivial.
- Using
express.json()beforeexpress.raw()on the same route — the body is already consumed.
Security and reliability in third-party integrations require attention to details like these. If you are building webhook infrastructure for a product that handles payments, compliance data, or customer records, Clixo can help you get it right.