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.
Webhook endpoints accept requests from outside your application. Verify the provider’s signature before using an event to update orders, payments, or account state.
The examples below demonstrate GitHub-style hexadecimal and Shopify-style base64 HMAC verification. Each provider defines its own signed input and header format.
How HMAC Webhook Signatures Work
With an HMAC scheme, the provider and receiver share a secret. Both compute a message authentication code over the provider-defined signed content, then the receiver compares the result with the supplied signature.
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
Algorithms differ. For example, Twilio documents HMAC-SHA1 for its request signatures. Use the provider’s SDK or implement its exact algorithm and canonicalization rules.
The Critical Rule: Use the Raw Body
For schemes that sign body bytes, use the raw, unmodified request body. Parsing and serializing JSON can alter whitespace or key order and invalidate the signature. Other schemes may also sign the URL, parameters, or a timestamp.
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...
For Stripe, verify the signature over the timestamp and raw body as well as enforcing the timestamp tolerance. A timestamp check alone does not authenticate a request. Prefer the official verification library, and deduplicate events because valid retries can arrive within the tolerance window.
Secret Rotation Without Downtime
When a provider supports overlapping secrets, rotation can follow this sequence:
- 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.
Follow the provider’s rotation mechanism. Immediate expiration may be necessary after compromise; planned rotation can use overlap to avoid rejecting legitimate deliveries.
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.