Your webhook endpoint is a public URL. Anyone who knows it can send requests. Without proper security, an attacker could send fake payment confirmations, trigger unauthorized actions, or mess with your data.
This guide covers how to secure your webhook endpoints properly.
Why Webhook Security Matters
Consider what could happen if someone sends a fake webhook to your endpoint:
- Fake payment confirmation — Grant access without actual payment
- Fake order webhook — Trigger fulfillment for non-existent orders
- Fake user events — Create or modify user accounts
- Denial of service — Flood your endpoint with requests
Attackers do scan for webhook endpoints. If you accept unsigned requests, you're vulnerable. This isn't theoretical — it happens.
Signature Verification
The primary defense is signature verification. Most webhook providers sign their payloads with HMAC (Hash-based Message Authentication Code). You verify the signature to confirm the request came from the real provider.
How HMAC Signing Works
- You share a secret key with the webhook provider
- When sending a webhook, the provider hashes the payload with your secret
- They include this hash in a header (e.g.,
X-Signature) - You compute the same hash and compare
- If they match, the request is authentic
Stripe Signature Verification
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Signature valid, process the event
handleEvent(event);
res.json({ received: true });
} catch (err) {
// Signature invalid
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
});
Shopify Signature Verification
const crypto = require('crypto');
function verifyShopifyWebhook(req) {
const hmac = req.headers['x-shopify-hmac-sha256'];
const body = req.rawBody; // Need raw body, not parsed JSON
const hash = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
.update(body, 'utf8')
.digest('base64');
return hmac === hash;
}
app.post('/webhook', (req, res) => {
if (!verifyShopifyWebhook(req)) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
handleWebhook(req.body);
res.status(200).send('OK');
});
GitHub Signature Verification
const crypto = require('crypto');
function verifyGitHubWebhook(req) {
const signature = req.headers['x-hub-signature-256'];
const body = req.rawBody;
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
.update(body)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Always use crypto.timingSafeEqual() or equivalent.
Regular string comparison (===) is vulnerable to
timing attacks that can reveal your secret.
Handling Raw Body
A common mistake: you parse the JSON body before verifying the signature. But signature verification needs the raw body exactly as received.
// Express: Capture raw body for webhook routes
app.use('/webhook', express.raw({ type: 'application/json' }));
// For other routes, use normal JSON parsing
app.use(express.json());
Timestamp Validation
Some providers (like Stripe) include a timestamp in the signature. This prevents replay attacks where someone captures a valid webhook and sends it again later.
// Stripe's constructEvent automatically checks timestamp
// Rejects webhooks older than 5 minutes by default
// For manual verification:
const tolerance = 300; // 5 minutes in seconds
const timestamp = parseInt(req.headers['stripe-signature-timestamp']);
const now = Math.floor(Date.now() / 1000);
if (now - timestamp > tolerance) {
throw new Error('Webhook timestamp too old');
}
Additional Security Measures
1. Use HTTPS
Always use HTTPS for webhook endpoints. HTTP is unencrypted and vulnerable to man-in-the-middle attacks. Most providers require HTTPS anyway.
2. Respond Quickly
Return a response as fast as possible (ideally under 5 seconds). Long-running processing should happen asynchronously after responding.
app.post('/webhook', async (req, res) => {
// Verify signature first
if (!verifySignature(req)) {
return res.status(401).send('Invalid');
}
// Acknowledge receipt immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});
3. Use Unique Endpoint URLs
Instead of /webhook, use a URL with a random component:
/webhook/a8f3b2c1-9d4e-5f6a-7b8c-9d0e1f2a3b4c
This adds obscurity — attackers can't guess the URL. It's not a replacement for signature verification, but it's an extra layer.
4. Rate Limiting
Implement rate limiting to prevent abuse. Even with signature verification, you don't want to process thousands of requests per second.
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many requests'
});
app.use('/webhook', webhookLimiter);
5. Log Everything
Log all webhook requests — successful and failed. This helps with debugging and detecting attacks.
app.post('/webhook', (req, res) => {
const logData = {
timestamp: new Date().toISOString(),
ip: req.ip,
headers: req.headers,
verified: false
};
if (!verifySignature(req)) {
logData.error = 'Invalid signature';
logger.warn('Webhook rejected', logData);
return res.status(401).send('Invalid');
}
logData.verified = true;
logData.eventType = req.body.type;
logger.info('Webhook received', logData);
// Process...
});
Development vs Production
During local development, you might want to skip signature verification (especially when replaying webhooks). But never in production.
function verifyWebhook(req) {
// ONLY skip in development
if (process.env.NODE_ENV === 'development' &&
process.env.SKIP_WEBHOOK_VERIFICATION === 'true') {
console.warn('⚠️ Skipping webhook verification (dev mode)');
return true;
}
return verifySignature(req);
}
Ensure your environment variables are set correctly. Accidentally deploying with verification disabled is a serious vulnerability.
Testing Security
Test that your security measures work:
- Send a request with no signature — should be rejected
- Send a request with wrong signature — should be rejected
- Send a request with old timestamp — should be rejected
- Send a valid request — should be accepted
With HookReplay, you can edit webhook payloads to test these scenarios. Modify the signature header and verify your handler rejects it.
Summary
Webhook security isn't optional. Without it, your endpoint is an open door for attackers.
Key takeaways:
- Always verify signatures using the provider's secret
- Use timing-safe comparison functions
- Preserve the raw body for signature verification
- Check timestamps to prevent replay attacks
- Add rate limiting and logging
- Never disable verification in production
A few lines of verification code protect your entire application.