Webhook Security Best Practices: HMAC Signature Verification
Protect your webhook endpoints with HMAC signature verification. Covers Stripe, GitHub, and Shopify examples plus common security pitfalls to avoid.
Ozer
Developer & Founder of HookSense
Your webhook endpoint is a publicly accessible URL. Anyone who discovers it can send crafted HTTP requests to trigger actions in your system — fake payment confirmations, bogus order notifications, or malicious data injection. Webhook signature verification is not optional; it is the single most important security measure for any webhook integration.
This guide covers HMAC signature verification in depth: how it works under the hood, step-by-step implementation for Stripe, GitHub, and Shopify, and the security pitfalls that catch even experienced developers.
Why Webhook Security Matters
Consider this scenario: you build an e-commerce site that fulfills orders when Stripe sends a checkout.session.completed webhook. Without signature verification, an attacker can:
- Send a fake webhook to your endpoint with a fabricated order
- Your server processes it as a legitimate purchase
- You ship a product without ever receiving payment
This is not theoretical. Webhook spoofing attacks happen regularly, and they are trivially easy to execute — a single curl command is all it takes. HMAC signature verification prevents this entirely.
How HMAC Works
HMAC (Hash-based Message Authentication Code) is a cryptographic construction that combines a hash function with a secret key. Here is the process:
- Shared secret: When you create a webhook endpoint, the provider gives you a signing secret (e.g., Stripe's
whsec_...). This secret is known only to you and the provider. - Signing: Before sending the webhook, the provider computes
HMAC-SHA256(secret, payload)wherepayloadis the raw HTTP request body. The resulting hash is included in a request header. - Verification: Your server receives the request, computes the same HMAC using your copy of the secret and the received body, then compares the result with the header value.
- Decision: If the values match, the request is authentic and untampered. If they differ, reject the request with a 401 status.
The beauty of HMAC is that an attacker cannot forge a valid signature without knowing the secret. Even a single byte change in the payload produces a completely different hash.
Stripe Implementation
Stripe uses a timestamp-based signature scheme. The Stripe-Signature header contains both a timestamp (t) and one or more signatures (v1):
Stripe-Signature: t=1614556828,v1=abc123def456...
The signed payload is constructed as timestamp.body, which prevents replay attacks. Here is the complete implementation:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Signature verified — safe to process
switch (event.type) {
case 'checkout.session.completed':
handleCheckoutComplete(event.data.object);
break;
case 'invoice.payment_failed':
handlePaymentFailed(event.data.object);
break;
}
res.json({ received: true });
}
);
Critical detail: The express.raw() middleware ensures the body is passed as a raw Buffer. If you use express.json() first, the body gets parsed and re-serialized, changing the bytes and breaking the signature.
GitHub Implementation
GitHub uses the X-Hub-Signature-256 header with a straightforward sha256=HASH format:
const crypto = require('crypto');
function verifyGitHubWebhook(req, secret) {
const signature = req.headers['x-hub-signature-256'];
if (!signature) return false;
const expected = 'sha256=' +
crypto.createHmac('sha256', secret)
.update(req.rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks/github', (req, res) => {
if (!verifyGitHubWebhook(req, process.env.GITHUB_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.headers['x-github-event'];
const payload = JSON.parse(req.rawBody);
switch (event) {
case 'push':
handlePush(payload);
break;
case 'pull_request':
handlePullRequest(payload);
break;
}
res.status(200).send('OK');
});
Shopify Implementation
Shopify uses the X-Shopify-Hmac-SHA256 header with a Base64-encoded signature:
function verifyShopifyWebhook(req, secret) {
const signature = req.headers['x-shopify-hmac-sha256'];
if (!signature) return false;
const computed = crypto.createHmac('sha256', secret)
.update(req.rawBody, 'utf8')
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(signature, 'base64'),
Buffer.from(computed, 'base64')
);
}
Note the base64 encoding — this is different from Stripe and GitHub which use hexadecimal. Always check the provider's documentation for the expected encoding format.
Security Pitfalls to Avoid
1. String Comparison Instead of Constant-Time Comparison
Never compare signatures with === or ==. Standard string comparison exits early when it finds a mismatched character, which leaks timing information. An attacker can use this to reconstruct the valid signature one character at a time.
// INSECURE — vulnerable to timing attacks
if (computedSignature === headerSignature) { ... }
// SECURE — constant-time comparison
if (crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(headerSignature)
)) { ... }
2. Not Validating Timestamps
Stripe includes a timestamp in the signature to prevent replay attacks. Always reject requests where the timestamp is more than 5 minutes old:
const tolerance = 300; // 5 minutes in seconds
const timestampAge = Math.floor(Date.now() / 1000) - timestamp;
if (timestampAge > tolerance) {
throw new Error('Webhook timestamp too old');
}
3. Storing Secrets in Code
Never hardcode webhook secrets in your source code. Use environment variables, a secrets manager (AWS Secrets Manager, HashiCorp Vault), or your platform's secret management (Vercel Environment Variables, Heroku Config Vars).
4. Skipping Verification in Development
It is tempting to bypass signature verification during development because you are testing with tools that do not sign requests. This is dangerous because the code path that skips verification can leak into production.
Instead, use HookSense, which supports built-in HMAC verification. Configure your webhook secret in the endpoint settings, and HookSense shows a "Verified" or "Invalid" badge on every request — so you can validate signatures without writing verification code during development.
5. Not Rotating Secrets
If a webhook secret is exposed (leaked in logs, committed to git, shared in a chat), rotate it immediately. During rotation, temporarily accept both the old and new secret to avoid dropping legitimate events:
function verifyWithFallback(body, signature, secrets) {
return secrets.some(secret => {
try {
stripe.webhooks.constructEvent(body, signature, secret);
return true;
} catch {
return false;
}
});
}
Testing Signature Verification
You can generate test signatures locally to verify your implementation works:
const crypto = require('crypto');
const secret = 'whsec_test_secret';
const payload = JSON.stringify({ type: 'test', data: {} });
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
console.log(`Stripe-Signature: t=${timestamp},v1=${signature}`);
Automatic Verification with HookSense
During development, manually testing signature verification is tedious. HookSense automates this entirely:
- Create an endpoint at hooksense.com
- Open endpoint settings and click the Shield icon
- Paste your webhook signing secret
- Select the provider (Stripe, GitHub, Shopify, or custom HMAC-SHA256)
- Every incoming request displays a verification badge instantly
This lets you confirm that your webhook provider is sending correctly signed requests before you invest time implementing verification in your own code.
Summary
Webhook signature verification is non-negotiable. Use HMAC-SHA256 with constant-time comparison, validate timestamps where available, never skip verification in any environment, and rotate secrets promptly when compromised. With proper verification in place, your webhook endpoints are protected against spoofing, tampering, and replay attacks.
Related
Try HookSense Free
Inspect, debug, and replay webhooks in real-time. No credit card required.
Get Started Free