Each platform signs webhook payloads with HMAC-SHA256 using a secret you set in their dashboard. In Node.js: Stripe uses stripe.webhooks.constructEvent(rawBody, sig, secret); Shopify compares X-Shopify-Hmac-SHA256 against crypto.createHmac('sha256', secret).update(rawBody).digest('base64'); GitHub compares X-Hub-Signature-256 against 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex').
Webhook Signature Verification in Node.js: Stripe, Shopify, and GitHub
Your webhook endpoint is a public URL. Anyone with that URL can POST JSON to it and — if you're not verifying signatures — your handler will process it as if it came from Stripe, Shopify, or GitHub. That means a malicious actor can fake a checkout.session.completed event to provision access without paying, or a push event to trigger your CI/CD pipeline.
Signature verification is the one security control that prevents this. Each platform generates an HMAC-SHA256 signature of the payload using a shared secret that only you and they know, and sends it in a request header. Your job is to recompute that signature and verify it matches. Here's exactly how to do it in Node.js for all three platforms.
Why does webhook signature verification matter?
Without verification, your webhook endpoint is a public API that:
- Accepts forged payment events from anyone who knows your endpoint URL
- Executes business logic (provisioning, fulfillment, access control changes) on untrusted data
- Is vulnerable to replay attacks where a previously valid payload is submitted again
- Cannot distinguish between a real platform event and an attacker's crafted payload
HMAC signatures solve this because they require knowledge of the signing secret to produce a valid signature. The secret is only known to you (from your dashboard) and the platform — it never appears in the request itself.
How do you configure Express to preserve the raw request body?
Before writing any signature verification code, you need to solve the most common mistake: all three platforms require you to compute the HMAC over the raw request body bytes, not the parsed JSON object. If you run the body through express.json() first, you lose the original byte sequence and the signature will never match.
Configure Express to expose the raw body:
import express from 'express'; const app = express(); // Store raw body buffer on req for signature verification app.use(express.raw({ type: 'application/json', verify: (req: any, _res, buf) => { req.rawBody = buf; }, })); // If you also need JSON parsing on other routes: // Mount webhook routes BEFORE express.json() middleware // or use express.raw() only on webhook-specific routes
Alternatively, use express.raw({ type: 'application/json' }) directly on your webhook routes. This gives you req.body as a Buffer rather than a parsed object.
How do you verify Stripe webhook signatures in Node.js?
Stripe makes this easy with its official SDK. The stripe.webhooks.constructEvent() method handles everything: it parses the Stripe-Signature header, validates the HMAC, and also checks that the timestamp embedded in the signature is within a 5-minute tolerance window (protecting against replay attacks).
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
// req.body is a Buffer here — do NOT parse it first
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error('Stripe signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: {err.message}`);
}
// Signature valid — event is authentic
console.log('Verified Stripe event:', event.type, event.id);
res.status(200).json({ received: true });
}
);
The Stripe-Signature header format looks like: t=1714000000,v1=abc123...,v0=def456.... The v1 value is the current signature scheme (HMAC-SHA256 of {timestamp}.{payload}). The SDK handles all of this for you — don't try to parse it manually.
Common mistake: using express.json() middleware before this route. Once the body is parsed to a JS object and re-stringified, the byte sequence changes and the HMAC won't match. Always use express.raw() on Stripe webhook routes.
How do you verify Shopify webhook signatures in Node.js?
Shopify sends the HMAC-SHA256 signature in the X-Shopify-Hmac-SHA256 header as a base64-encoded string. You compute the same HMAC using the raw body and your Shopify Client Secret, then compare using crypto.timingSafeEqual() to prevent timing attacks.
import crypto from 'crypto';
import express from 'express';
app.post(
'/webhooks/shopify',
express.raw({ type: 'application/json' }),
(req, res) => {
const receivedHmac = req.headers['x-shopify-hmac-sha256'] as string;
if (!receivedHmac) {
return res.status(401).send('Missing signature header');
}
// Compute expected HMAC — base64-encoded SHA256 of raw body
const expectedHmac = crypto
.createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET!)
.update(req.body) // req.body is a Buffer
.digest('base64');
// Timing-safe comparison — prevents timing oracle attacks
const receivedBuf = Buffer.from(receivedHmac, 'base64');
const expectedBuf = Buffer.from(expectedHmac, 'base64');
if (
receivedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(receivedBuf, expectedBuf)
) {
console.error('Shopify HMAC verification failed');
return res.status(401).send('Invalid signature');
}
// Signature valid — parse body and handle event
const payload = JSON.parse(req.body.toString('utf8'));
const topic = req.headers['x-shopify-topic'] as string;
const webhookId = req.headers['x-shopify-webhook-id'] as string;
console.log('Verified Shopify event:', topic, webhookId);
res.status(200).json({ received: true });
}
);
Note the timingSafeEqual length check: if the two buffers have different lengths, timingSafeEqual will throw a RangeError. Always check lengths first and return 401 if they differ.
Common mistake: using === for string comparison instead of crypto.timingSafeEqual(). A naive string comparison leaks timing information — an attacker can measure response time to infer how many leading bytes of their guessed signature are correct.
How do you verify GitHub webhook signatures in Node.js?
GitHub sends the HMAC-SHA256 signature in the X-Hub-Signature-256 header as a hex string prefixed with sha256=. You compute the same hex HMAC and compare with crypto.timingSafeEqual().
import crypto from 'crypto';
import express from 'express';
app.post(
'/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const receivedSig = req.headers['x-hub-signature-256'] as string;
if (!receivedSig) {
return res.status(401).send('Missing signature header');
}
// Compute expected signature — hex HMAC prefixed with 'sha256='
const expectedSig =
'sha256=' +
crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!)
.update(req.body) // req.body is a Buffer
.digest('hex');
// Timing-safe comparison
const receivedBuf = Buffer.from(receivedSig, 'utf8');
const expectedBuf = Buffer.from(expectedSig, 'utf8');
if (
receivedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(receivedBuf, expectedBuf)
) {
console.error('GitHub signature verification failed');
return res.status(401).send('Invalid signature');
}
// Signature valid — parse body and handle event
const payload = JSON.parse(req.body.toString('utf8'));
const eventType = req.headers['x-github-event'] as string;
const deliveryId = req.headers['x-github-delivery'] as string;
console.log('Verified GitHub event:', eventType, deliveryId);
res.status(200).json({ received: true });
}
); Common mistake 1: forgetting the sha256= prefix when computing expectedSig. GitHub's header value is sha256=abc123..., not just abc123.... If you compare the raw hex to the prefixed header value, it will always fail.
Common mistake 2: GitHub also sends a legacy X-Hub-Signature header using SHA1. Never use SHA1 — only verify against X-Hub-Signature-256.
What are the most common signature verification mistakes?
- Parsing JSON before reading the raw body (Stripe). Using
express.json()upstream of your Stripe route meansreq.bodyis already a JS object. CallingconstructEvent()with a non-Buffer body causes a "No signatures found matching" error. - Using
===for signature comparison (Shopify, GitHub). String equality comparison is not timing-safe and leaks information. Always usecrypto.timingSafeEqual()with Buffer arguments. - Missing the
sha256=prefix (GitHub). GitHub'sX-Hub-Signature-256header value starts withsha256=. Your computed value must include this prefix before comparison. - Using the wrong secret (all platforms). Stripe uses the endpoint's signing secret (found in Dashboard → Webhooks → [endpoint] → Signing secret), not your Stripe secret key. Shopify uses your app's client secret. GitHub uses the per-webhook secret you set in the repository settings.
- Not setting a webhook secret at all (GitHub). GitHub silently omits the
X-Hub-Signature-256header if you didn't set a secret when creating the webhook. Always set a secret. - Comparing strings of different encodings. For Shopify, the header is base64; for GitHub, it's hex (prefixed). Ensure your computed digest uses the same encoding before comparing.
FAQ: Webhook signature verification in Node.js
How does Stripe webhook signature verification work?
{timestamp}.{raw_payload} using your endpoint's signing secret and sends the result in the Stripe-Signature header alongside the timestamp. In Node.js, call stripe.webhooks.constructEvent(rawBody, req.headers['stripe-signature'], signingSecret). The SDK validates the HMAC and rejects requests older than 5 minutes to prevent replay attacks. You must pass the raw body buffer — not parsed JSON.How do I verify Shopify webhook signatures in Node.js?
X-Shopify-Hmac-SHA256 header. Compute crypto.createHmac('sha256', SHOPIFY_CLIENT_SECRET).update(rawBodyBuffer).digest('base64'), then compare using crypto.timingSafeEqual() — never with ===. Always compare Buffer objects of equal length to avoid RangeError.What is the X-Hub-Signature-256 header from GitHub?
sha256={hex_digest}. In Node.js, compute 'sha256=' + crypto.createHmac('sha256', secret).update(rawBodyBuffer).digest('hex') and compare using crypto.timingSafeEqual(). Don't use the legacy X-Hub-Signature (SHA1) header.Secure webhooks start with signature verification — and reliable webhooks start with monitoring. Webhook Guardian detects delivery failures across Stripe, Shopify, and GitHub and alerts you within 5 minutes. Start free for 14 days →
Also read: Webhook security guide · Webhook reliability best practices · Stripe monitoring · Shopify monitoring · GitHub monitoring