Webhooks allow platforms like Stripe, GitHub, Shopify, and Twilio to notify your application asynchronously when events occur (e.g., successful payments, repository pushes, or SMS delivery).
However, because webhook endpoints are public HTTP URLs exposed to the internet, anyone can send fake POST requests to your server. Without robust cryptographic verification, an attacker can spoof payment confirmations, trigger unauthorized account upgrades, or execute denial-of-service payloads.
This production guide explains the HMAC signature verification architecture, common implementation traps like body-parser mutation and timing attacks, and provides end-to-end code implementations across Node.js, Python, and Go.
1. Webhook Security Architecture
Modern webhook providers secure event delivery using Hash-based Message Authentication Codes (HMAC) with a shared secret:
Provider (Sender) Consumer (Your Server)
| |
| 1. Generate Payload JSON |
| 2. Compute Signature = HMAC-SHA256(secret, payload) |
| 3. Send HTTP POST + X-Signature-256 Header |
| -----------------------------------------------------> |
| | 4. Receive raw payload
| | 5. Recompute HMAC-SHA256
| | 6. Compare signatures safely
| | 7. Process event or return 401
Key Components of a Secure Webhook Contract
- Shared Secret: A high-entropy random string known only to the sender and your application.
- Payload Hash (HMAC-SHA256): A cryptographic digest created over the exact raw body bytes.
- Timestamp Header (
X-Timestamp/t=...): Protects against replay attacks by embedding the sender's dispatch time. - Constant-Time Comparison: Prevents side-channel timing attacks when validating the signature hash.
2. Top 4 Common Webhook Implementation Traps
1. The Raw Body vs. JSON Body Mutation Trap
The most frequent bug in webhook verification is computing the hash over JSON.stringify(req.body) instead of the raw, unparsed request payload.
- Why it breaks: JSON deserialization and reserialization changes key ordering, whitespace, unicode character escapes, and number formatting. Even a single changed byte alters the entire SHA-256 hash.
- The Fix: Capture the raw request buffer before your framework's JSON body-parser middleware runs.
Express.js Raw Body Setup
import express from 'express';
const app = express();
// Use express.raw for webhook routes before express.json()
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body; // Buffer
// Verify signature with rawBody...
}
);
// General routes use standard JSON parser
app.use(express.json());
Next.js App Router Raw Body Setup
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
// Read as raw string / ArrayBuffer before parsing
const rawBody = await req.text();
const signature = req.headers.get('x-signature-256');
// Verify signature with rawBody...
}
2. Timing Attacks (=== vs timingSafeEqual)
Standard string comparison operators (=== in JS, == in Python) perform short-circuit evaluation, returning false on the first non-matching byte. An attacker can measure microsecond latency differences to guess the signature byte-by-byte.
- The Fix: Always use constant-time comparison utilities:
- Node.js:
crypto.timingSafeEqual(bufferA, bufferB) - Python:
hmac.compare_digest(sig_a, sig_b) - Go:
subtle.ConstantTimeCompare([]byte(sigA), []byte(sigB))
- Node.js:
3. Replay Attacks (Missing Timestamp Tolerance)
If an attacker intercepts a legitimate webhook request over an insecure network proxy, they can resend the exact same request repeatedly.
- The Fix: Require providers to include a timestamp in the header (e.g.
t=1798687200,v1=...). Enforce a maximum tolerance window (e.g., 5 minutes / 300 seconds):
$$\text{Current Time} - \text{Header Timestamp} \le 300\text{ seconds}$$
4. Encoding Mismatches (Hex vs Base64)
Some providers encode signatures as hexadecimal strings (v1=4a5f...), while others use Base64 (v1=Sl+/...). Ensure your decoder matches the provider's specification before comparison.
3. Production Code Implementations
Node.js / TypeScript (Next.js & Native Crypto)
import crypto from 'node:crypto';
interface VerifyWebhookOptions {
rawBody: string;
signatureHeader: string;
secret: string;
toleranceSeconds?: number;
}
export function verifyWebhookSignature({
rawBody,
signatureHeader,
secret,
toleranceSeconds = 300,
}: VerifyWebhookOptions): boolean {
// 1. Parse timestamp and signature components (e.g., 't=1798687200,v1=abcdef...')
const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const signaturePart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !signaturePart) {
return false;
}
const timestamp = parseInt(timestampPart.slice(2), 10);
const receivedSignature = signaturePart.slice(3);
// 2. Check for replay attacks
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > toleranceSeconds) {
return false; // Timestamp out of tolerance window
}
// 3. Compute expected HMAC
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 4. Constant-time comparison
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
const receivedBuffer = Buffer.from(receivedSignature, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
Python (FastAPI / Standard Library)
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException, status
app = FastAPI()
WEBHOOK_SECRET = "whsec_your_production_secret"
TOLERANCE_SECONDS = 300
@app.post("/api/webhooks")
async def handle_webhook(request: Request):
# Read raw unparsed body
raw_body = await request.body()
sig_header = request.headers.get("x-signature-256")
if not sig_header:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing signature header")
try:
# Example format: t=1798687200,v1=abcdef...
items = dict(item.split("=") for item in sig_header.split(","))
timestamp = int(items["t"])
received_sig = items["v1"]
except (ValueError, KeyError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Malformed signature header")
# Verify timestamp window
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Timestamp out of tolerance window")
# Compute expected signature
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
computed_sig = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(computed_sig, received_sig):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
# Safe to parse JSON and process event
event = await request.json()
return {"status": "success", "event_id": event.get("id")}
4. Webhook Security Checklist for Production
- Enforce TLS 1.3 / HTTPS: Never accept webhooks over unencrypted HTTP endpoints.
- Verify Before Parsing: Validate the HMAC signature before executing any application logic or database queries.
- Return HTTP 2xx Immediately: Acknowledge receipt within 2–5 seconds with
200 OKor202 Acceptedand offload long-running tasks to background job queues (e.g. BullMQ, Celery, SQS). - Implement Idempotency: Webhook senders retry on network drops. Use unique event IDs (
event_idoridempotency_key) to prevent duplicate processing (e.g. charging a customer twice). - Rotate Webhook Secrets Gracefully: Support dual-secret verification during credential rotations to prevent service interruption.
Frequently Asked Questions
Why can't I just use a secret bearer token in the query params or Authorization header?
Bearer tokens prove identity but do not prove integrity. A bearer token does not prevent man-in-the-middle tampering of the payload body. HMAC signatures guarantee both sender authenticity and that the payload was not modified in transit.
How do I test webhook verification locally during development?
Use a local tunneling tool or generate mock webhook payloads. You can compute test HMAC digests and validate headers using the Hash Generator Tool and simulate request dispatching with the Webhook Tester Tool.