Webhook Security
Your webhook endpoint is a public URL. Anyone who finds it can send requests to it. Without proper security, attackers could:
- Trigger fake payment confirmations
- Inject malicious data into your system
- Cause your app to take unauthorized actions
An attacker discovers your /webhooks/stripe endpoint. They send a fake payment_intent.succeeded event. Without verification, your app fulfills an order that was never paid for.
Signature Verification
The solution: cryptographic signatures. The webhook sender creates a signature using a shared secret that only you and they know.
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ SENDER (e.g., Stripe) │
│ │
│ 1. payload = '{"type": "payment_intent.succeeded", ...}' │
│ 2. signature = HMAC-SHA256(payload, shared_secret) │
│ 3. Send: payload + signature header │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ RECEIVER (Your App) │
│ │
│ 1. Receive: payload + signature header │
│ 2. expected = HMAC-SHA256(payload, shared_secret) │
│ 3. Compare: signature === expected ? │
│ ✓ Match: Process webhook │
│ ✗ No match: Reject (401/403) │
└─────────────────────────────────────────────────────────────────┘
HMAC Explained
HMAC (Hash-based Message Authentication Code) combines a hash function with a secret key:HMAC-SHA256("Hello World", "my-secret-key")
// → "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
What HMAC Proves
- Authenticity: Sender knows the secret
- Integrity: Payload wasn't modified
What HMAC Doesn't Do
- Encrypt the payload (still plaintext)
- Prevent replay attacks (need timestamp)
Stripe Signature Verification
Stripe's signature header format:
Stripe-Signature: t=1699999999,v1=abc123def456...
t= Unix timestamp when sentv1= HMAC-SHA256 signature
Implementation
const crypto = require('crypto');
const express = require('express');
const app = express();
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
// Must use raw body for signature verification
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['stripe-signature'];
const payload = req.body;
try {
// Parse signature header
const elements = signature.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key] = value;
return acc;
}, {});
const timestamp = elements['t'];
const receivedSignature = elements['v1'];
// Check timestamp (prevent replay attacks)
const tolerance = 300; // 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > tolerance) {
throw new Error('Timestamp too old');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', STRIPE_WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Secure comparison (timing-attack safe)
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
)) {
throw new Error('Invalid signature');
}
// Signature valid - process event
const event = JSON.parse(payload);
console.log('Verified event:', event.type);
res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook verification failed:', err.message);
res.status(401).json({ error: 'Invalid signature' });
}
}
);
Using Stripe's SDK (Recommended)
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['stripe-signature'];
try {
// SDK handles all verification
const event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
// Event is verified - safe to process
switch (event.type) {
case 'payment_intent.succeeded':
handlePaymentSuccess(event.data.object);
break;
// ... other events
}
res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook error:', err.message);
res.status(400).json({ error: err.message });
}
}
);
GitHub Signature Verification
GitHub uses a similar HMAC approach with X-Hub-Signature-256 header:
const crypto = require('crypto');
function verifyGitHubWebhook(payload, signature, secret) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.post('/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'];
const payload = req.body;
if (!verifyGitHubWebhook(payload, signature, GITHUB_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
const eventType = req.headers['x-github-event'];
console.log(`GitHub event: ${eventType}`);
// Process verified event...
res.status(200).json({ received: true });
}
);
Timing-Safe Comparison
Normal string comparison (===) stops at the first different character, leaking timing information. Attackers can use this to guess the signature one character at a time. timingSafeEqual always takes the same time regardless of where strings differ.
// BAD: Vulnerable to timing attacks
if (signature === expectedSignature) { ... }
// GOOD: Constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { ... }
Replay Attack Prevention
Even with valid signatures, an attacker could capture a webhook and resend it. Use timestamps:
function verifyTimestamp(timestamp, toleranceSeconds = 300) {
const webhookTime = parseInt(timestamp);
const currentTime = Math.floor(Date.now() / 1000);
const difference = Math.abs(currentTime - webhookTime);
if (difference > toleranceSeconds) {
throw new Error(`Webhook too old: ${difference} seconds`);
}
return true;
}
Timestamp in signature
Stripe includes timestamp in the signed payload. Changing it invalidates the signature.
Tolerance window
Allow 5-minute tolerance for clock drift between servers.
Additional Security Measures
1. IP Allowlisting
Some providers publish their webhook IP ranges:
const STRIPE_IPS = [
'3.18.12.63',
'3.130.192.231',
// ... more IPs from Stripe's documentation
];
function verifyIP(req) {
const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
return STRIPE_IPS.includes(clientIP);
}
IP allowlisting is defense-in-depth but shouldn't be your only protection. IPs can change, and you might be behind proxies. Always use signature verification as the primary security measure.
2. Use HTTPS Only
// Reject non-HTTPS in production
app.use('/webhooks', (req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.status(403).json({ error: 'HTTPS required' });
}
next();
});
3. Unique, Secret URLs
Add a random path component:
Instead of: https://yourapp.com/webhooks/stripe
Use: https://yourapp.com/webhooks/stripe/a8f9c3b2e1d4
const WEBHOOK_PATH_SECRET = process.env.WEBHOOK_PATH_SECRET;
app.post(`/webhooks/stripe/${WEBHOOK_PATH_SECRET}`, (req, res) => {
// Extra layer - attackers must guess the URL
});
Security Checklist
Before Going Live
- Signature verification implemented for all webhooks
- Using timing-safe comparison for signatures
- Timestamp validation to prevent replay attacks
- HTTPS enforced in production
- Webhook secrets stored securely (environment variables)
- Raw body parsing (not JSON parsed) for signature verification
- Failed verifications logged for monitoring
Common Mistakes
Parsing JSON Before Verification
JSON.parse can change the payload (whitespace, key order). Always verify the raw body string.
Skipping Verification in Development
Easy to forget to enable in production. Always verify, even locally with test secrets.
Logging Webhook Secrets
Never log secrets or full payloads with sensitive data. Redact before logging.
Single Secret for All Environments
Use different secrets for development, staging, and production.
Key Takeaways
- Always verify signatures - Never trust unverified webhooks
- Use timing-safe comparison - Prevent timing attacks
- Validate timestamps - Prevent replay attacks
- Verify raw body, not parsed JSON - Parsing can change content
Next up: Reliability Patterns - Handling retries, failures, and ensuring you never miss an event.