Webhook Security & Signature Verification
Because webhook receivers are public HTTP endpoints, anyone on the internet who discovers your URL can send arbitrary HTTP POST requests to it.
Without proper security, an attacker could forge a fake payment_intent.succeeded event, tricking your server into shipping thousands of dollars worth of goods that were never paid for.
1. How Webhook Signatures Work (HMAC-SHA256)
To ensure authenticity and integrity, webhook providers sign every payload using a Shared Webhook Secret known only to the sender and your application.
What HMAC Proves:
- Authenticity: Only someone in possession of the shared secret could have created the matching signature.
- Integrity: If an attacker tampers with even a single character in the JSON payload, the signature verification immediately fails.
2. Implementing Stripe Signature Verification in Node.js
Stripe formats its signature header with two parts: a Unix timestamp (t) and the signature hash (v1):
Stripe-Signature: t=1699999999,v1=9f83a48e71c6...
Manual Verification Implementation (Using Node.js crypto)
import crypto from "crypto";
import express from "express";
const app = express();
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
// ⚠️ CRITICAL: Must use express.raw() to preserve the exact raw byte string
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const signatureHeader = req.headers["stripe-signature"];
const rawPayload = req.body.toString("utf-8");
if (!signatureHeader) {
return res.status(401).json({ error: "Missing signature header" });
}
try {
// 1. Parse header components (timestamp 't' and signature 'v1')
const parts = signatureHeader.split(",").reduce((acc, part) => {
const [k, v] = part.split("=");
acc[k] = v;
return acc;
}, {});
const timestamp = parts["t"];
const receivedSignature = parts["v1"];
// 2. Prevent Replay Attacks: Check if timestamp is older than 5 minutes (300 seconds)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) {
throw new Error("Timestamp tolerance exceeded (possible replay attack)");
}
// 3. Compute the expected HMAC signature
const signedPayload = `${timestamp}.${rawPayload}`;
const expectedSignature = crypto
.createHmac("sha256", STRIPE_WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
// 4. Timing-safe comparison to prevent side-channel timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSignature, "hex"),
Buffer.from(expectedSignature, "hex")
);
if (!isValid) {
throw new Error("Signatures do not match");
}
// 5. Signature verified! Safely parse JSON and process event
const event = JSON.parse(rawPayload);
console.log(`âś… Verified incoming event: ${event.type}`);
res.status(200).json({ received: true });
} catch (err) {
console.error(`❌ Verification failed: ${err.message}`);
res.status(401).json({ error: "Unauthorized webhook" });
}
}
);Recommended: Using the Official Stripe SDK
In production, you can let the official stripe SDK handle the signature verification and replay checking automatically:
import Stripe from "stripe";
import express from "express";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
// Validates signature, payload integrity, and timestamp in one call
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`⚠️ Webhook signature failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Safely handle event
if (event.type === "payment_intent.succeeded") {
const paymentIntent = event.data.object;
console.log(`Payment confirmed: ${paymentIntent.id}`);
}
res.status(200).json({ received: true });
}
);3. GitHub Webhook Signature Verification
GitHub signs payloads using the X-Hub-Signature-256 header:
import crypto from "crypto";
import express from "express";
const app = express();
const GITHUB_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
function verifyGitHubWebhook(rawPayload, headerSignature, secret) {
if (!headerSignature || !headerSignature.startsWith("sha256=")) {
return false;
}
const expectedSignature =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(rawPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(headerSignature),
Buffer.from(expectedSignature)
);
}
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-hub-signature-256"];
const rawBody = req.body.toString("utf-8");
if (!verifyGitHubWebhook(rawBody, signature, GITHUB_SECRET)) {
return res.status(401).json({ error: "Invalid GitHub signature" });
}
const eventType = req.headers["x-github-event"];
const payload = JSON.parse(rawBody);
console.log(`Received verified GitHub event: ${eventType}`);
res.status(200).json({ received: true });
}
);4. Understanding Timing Attacks & timingSafeEqual
❌ The Vulnerability: Standard === String Comparison
Normal string equality (strA === strB) compares characters sequentially from left to right and exits immediately on the first mismatched character.
An attacker can measure sub-millisecond response times to guess valid signature characters one-by-one.
// ❌ VULNERABLE TO TIMING ATTACKS
if (receivedSig === expectedSig) { ... }âś… The Fix: Constant-Time Comparison
crypto.timingSafeEqual() takes the exact same number of CPU cycles regardless of where characters differ, completely eliminating timing side-channels.
// âś… SECURE
const isMatch = crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
);5. Defense-in-Depth Security Checklist
- Always Verify Raw Request Body: Parsing JSON before verification modifies whitespace and property ordering, causing signatures to fail.
- Enforce HTTPS: Reject all unencrypted HTTP traffic in production (
req.secure). - Use Secret Route Tokens: Add a random secret path component (e.g.,
/webhooks/stripe/a8f9c3b2e1d4) so automated scanners cannot easily discover your endpoint. - Environment Isolation: Never reuse development webhook secrets in production.
👉 Next Step: Make your webhook handlers bulletproof with idempotency and background queues in Reliability, Idempotency & Queue Patterns.