Webhook URLs are public endpoints that receive data from external services. Without validation, attackers can forge webhook requests to trigger actions in your system.
Key Takeaways
- 1Always verify webhook signatures before processing
- 2Use timing-safe comparison to prevent timing attacks
- 3Validate timestamp to prevent replay attacks
- 4Store signing secrets securely (env vars, not code)
- 5Return 200 status even for invalid requests (prevents probing)
"HMAC can be used in combination with any iterated cryptographic hash function. The cryptographic strength of HMAC depends on the properties of the underlying hash function."
Why Validation Matters
Webhook URLs are typically public endpoints that anyone can send requests to. Without signature validation, attackers can forge webhook payloads to trigger actions in your system. The table below shows real-world attacks that signature validation prevents.
| Attack | Impact |
|---|---|
| Forged payment webhook | Orders fulfilled without payment |
| Fake user creation | Spam accounts, unauthorized access |
| Replay attack | Action triggered multiple times |
| Event injection | Trigger workflows with fake data |
Forged payment webhooks are particularly dangerous. Without validation, an attacker could send fake "payment successful" events to fulfill orders without paying. Replay attacks reuse legitimate webhooks to trigger duplicate actions. Understanding these threats emphasizes why every webhook endpoint needs signature validation.
The industry standard for webhook authentication is HMAC (Hash-based Message Authentication Code). Let's look at how to implement it correctly.
HMAC Signature Verification
HMAC verification works by computing a hash of the webhook payload using a shared secret known only to you and the sender. If the computed hash matches the signature in the request header, the webhook is authentic. The key security detail is using timing-safe comparison to prevent timing attacks.
import crypto from 'crypto';
// Timing-safe comparison (prevents timing attacks)
function secureCompare(a, b) {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
// Generic HMAC verification
function verifyWebhookSignature(payload, signature, secret, algorithm = 'sha256') {
const expectedSignature = crypto
.createHmac(algorithm, secret)
.update(payload, 'utf8')
.digest('hex');
return secureCompare(signature, expectedSignature);
}The secureCompare function uses crypto.timingSafeEqual to compare signatures in constant time. Regular string comparison (===) returns early on the first mismatched character, allowing attackers to guess the signature byte-by-byte by measuring response times. Timing-safe comparison takes the same time regardless of where the mismatch occurs.
Let's look at how major webhook providers implement signatures and how to validate them correctly.
Stripe Webhooks
Stripe provides an official SDK that handles signature verification for you. The constructEvent method validates the signature and parses the event in one step. Using the SDK is strongly recommended since it handles edge cases and receives security updates automatically.
import Stripe from 'stripe';
const stripe = new 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 signature = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
signature,
endpointSecret
);
// Process the event
switch (event.type) {
case 'payment_intent.succeeded':
handlePaymentSuccess(event.data.object);
break;
// ... other events
}
res.status(200).send('OK');
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
res.status(400).send('Webhook Error');
}
});Notice the use of express.raw() to get the raw request body. The signature is computed over the raw bytes, so if you parse the JSON first, you may get a different signature due to formatting differences. Always validate against the raw body, then parse after validation succeeds.
GitHub Webhooks
GitHub webhooks use a straightforward HMAC-SHA256 signature in the X-Hub-Signature-256 header. The signature is prefixed with sha256= to indicate the algorithm. GitHub also sends the event type in a separate header, which you should use to route events to appropriate handlers.
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-hub-signature-256'];
if (!signature) {
return res.status(400).send('Missing signature');
}
const payload = req.body;
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', GITHUB_WEBHOOK_SECRET)
.update(payload)
.digest('hex');
if (!secureCompare(signature, expectedSignature)) {
return res.status(401).send('Invalid signature');
}
// Process webhook
const event = req.headers['x-github-event'];
const data = JSON.parse(payload);
// ...
res.status(200).send('OK');
});This implementation manually computes the HMAC and compares it with timing-safe comparison. The sha256= prefix must be included when comparing signatures. Always return early with a 400 status if the signature header is missing to avoid processing unsigned requests.
Slack Webhooks
Slack includes a timestamp in its signature scheme to prevent replay attacks. The signature is computed over a combination of the version identifier, timestamp, and request body. Slack recommends rejecting requests older than 5 minutes to limit the replay window.
const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET;
app.post('/webhooks/slack', express.raw({ type: 'application/x-www-form-urlencoded' }), (req, res) => {
const timestamp = req.headers['x-slack-request-timestamp'];
const signature = req.headers['x-slack-signature'];
// Prevent replay attacks (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 60 * 5) {
return res.status(400).send('Request too old');
}
const sigBasestring = `v0:${timestamp}:${req.body}`;
const expectedSignature = 'v0=' + crypto
.createHmac('sha256', SLACK_SIGNING_SECRET)
.update(sigBasestring, 'utf8')
.digest('hex');
if (!secureCompare(signature, expectedSignature)) {
return res.status(401).send('Invalid signature');
}
// Process Slack event
res.status(200).send('OK');
});The Slack implementation demonstrates timestamp validation: it checks that the request timestamp is within 5 minutes of the current time before validating the signature. This prevents attackers from capturing a valid webhook and replaying it later. The signature base string format (v0:timestamp:body) is specific to Slack's signing scheme.
Beyond provider-specific implementations, several best practices apply to all webhook endpoints regardless of the sender.
Best Practices
Secure webhook handling involves more than just signature validation. The table below summarizes key practices that protect your webhook endpoints from various attack vectors. Following these guidelines consistently helps ensure your webhook integrations remain secure.
| Practice | Why |
|---|---|
| Use timing-safe comparison | Prevents attackers from guessing signature byte-by-byte |
| Validate timestamp | Prevents replay of old requests |
| Parse body AFTER validation | Don't trust unvalidated input |
| Log failures (not secrets) | Audit attempted attacks |
| Return 200 even for invalid | Prevents endpoint probing |
The "return 200 even for invalid" practice deserves special attention. Returning different status codes for valid vs. invalid signatures lets attackers probe your endpoint to confirm it exists and test signature validity. A consistent 200 response reveals nothing about the validation result, though you should still log failures internally for security monitoring.