Webhook Security Guide
Ensure your webhook endpoint is secure and reliable with these implementation guidelines.
Overview
Webhook security is critical because:
- 🎯 Webhooks receive data from external sources
- 🔓 Endpoints are publicly accessible
- 💰 They may trigger business-critical actions
- 🛡️ They're targets for potential attacks
Signature Verification
Understanding the Signature
Every webhook request includes a signature in the X-Webhook-Signature header:
http
POST /your-webhook-endpoint
Content-Type: application/json
X-Webhook-Signature: 3b4c9d8e2f1a6b5c4d3e2f1a6b5c4d3e2f1a6b5cThis signature is computed using:
- Algorithm: HMAC SHA-256
- Secret: Your webhook secret (provided during creation)
- Payload: The exact request body
Implementation Examples
javascript
const crypto = require('crypto');
// Middleware for webhook verification
function verifyWebhook(secret) {
return (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
if (!signature) {
return res.status(401).json({ error: 'No signature provided' });
}
// Important: Use raw body for signature verification
const payload = JSON.stringify(req.body);
const computedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computedSignature)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
}
// Usage
app.post('/webhook',
express.json({ verify: (req, res, buf) => { req.rawBody = buf } }),
verifyWebhook(process.env.WEBHOOK_SECRET),
async (req, res) => {
// Process verified webhook
console.log('Verified webhook:', req.body.event);
res.status(200).send('OK');
}
);python
import hmac
import hashlib
import json
from flask import Flask, request, abort
app = Flask(__name__)
def verify_webhook_signature(payload, signature, secret):
"""Verify the webhook signature"""
computed = hmac.new(
secret.encode(),
json.dumps(payload).encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, computed)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature')
if not signature:
abort(401, 'No signature provided')
payload = request.get_json()
if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
abort(401, 'Invalid signature')
# Process verified webhook
print(f"Verified webhook: {payload['event']}")
return 'OK', 200php
<?php
function verifyWebhookSignature($payload, $signature, $secret) {
$computed = hash_hmac('sha256', json_encode($payload), $secret);
return hash_equals($signature, $computed);
}
// Get headers and body
$headers = getallheaders();
$signature = $headers['X-Webhook-Signature'] ?? null;
$payload = json_decode(file_get_contents('php://input'), true);
if (!$signature) {
http_response_code(401);
exit('No signature provided');
}
if (!verifyWebhookSignature($payload, $signature, $_ENV['WEBHOOK_SECRET'])) {
http_response_code(401);
exit('Invalid signature');
}
// Process verified webhook
error_log("Verified webhook: " . $payload['event']);
http_response_code(200);
echo 'OK';
?>go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
h := hmac.New(sha256.New, []byte(secret))
h.Write(payload)
computed := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(signature), []byte(computed))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Webhook-Signature")
if signature == "" {
http.Error(w, "No signature provided", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
if !verifyWebhookSignature(body, signature, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process verified webhook
var payload map[string]interface{}
json.Unmarshal(body, &payload)
log.Printf("Verified webhook: %s", payload["event"])
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}Security Best Practices
1. 🔒 Use HTTPS Only
Never accept webhooks over HTTP:
nginx
# Nginx configuration
server {
listen 80;
server_name webhook.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name webhook.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location /webhook {
proxy_pass http://localhost:3000;
}
}2. 🛡️ Implement IP Whitelisting (Optional)
For additional security, whitelist Attivita's IP addresses:
javascript
const ALLOWED_IPS = [
// Contact support for current IP list
];
function checkIP(req, res, next) {
const clientIP = req.ip || req.connection.remoteAddress;
if (!ALLOWED_IPS.includes(clientIP)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}3. ⏱️ Validate Timestamps
Reject old webhooks to prevent replay attacks:
javascript
function validateTimestamp(timestamp, maxAgeSeconds = 300) {
const now = Date.now();
const webhookTime = parseInt(timestamp);
const age = (now - webhookTime) / 1000;
if (age > maxAgeSeconds) {
throw new Error(`Webhook too old: ${age}s`);
}
}
// In your webhook handler
try {
validateTimestamp(req.body.timestamp);
} catch (error) {
return res.status(400).json({ error: error.message });
}4. 🔄 Implement Idempotency
Handle duplicate webhooks gracefully:
javascript
const processedWebhooks = new Map();
async function processWebhook(event) {
// Create unique key
const eventKey = `${event.event}-${event.timestamp}-${event.data.productId}`;
// Check if already processed
if (processedWebhooks.has(eventKey)) {
console.log('Duplicate webhook, skipping:', eventKey);
return { status: 'duplicate' };
}
// Mark as processing
processedWebhooks.set(eventKey, 'processing');
try {
// Process the event
const result = await handleEvent(event);
// Mark as completed
processedWebhooks.set(eventKey, 'completed');
// Clean up old entries after 24 hours
setTimeout(() => processedWebhooks.delete(eventKey), 86400000);
return result;
} catch (error) {
// Mark as failed
processedWebhooks.set(eventKey, 'failed');
throw error;
}
}5. 🚦 Rate Limiting
Protect your endpoint from abuse:
javascript
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests'
});
app.post('/webhook', webhookLimiter, verifyWebhook, handler);Error Handling
Graceful Failure Handling
javascript
async function webhookHandler(req, res) {
try {
// Acknowledge receipt immediately
res.status(200).send('OK');
// Process asynchronously
await processWebhookAsync(req.body);
} catch (error) {
// Log error but still return 200
// This prevents webhook retries for processing errors
console.error('Webhook processing error:', error);
// Optional: Send alert
await notifyOps({
error: error.message,
webhook: req.body,
timestamp: new Date()
});
}
}
async function processWebhookAsync(payload) {
// Validate payload structure
if (!payload.event || !payload.data) {
throw new Error('Invalid webhook payload structure');
}
// Process based on event type
switch (payload.event) {
case 'productOutOfStock':
await handleOutOfStock(payload.data);
break;
case 'productBackInStock':
await handleBackInStock(payload.data);
break;
case 'productPriceChanged':
await handlePriceChange(payload.data);
break;
default:
console.warn('Unknown event type:', payload.event);
}
}Testing Your Implementation
1. Local Testing with ngrok
bash
# Start your local server
npm run dev
# In another terminal, expose it with ngrok
ngrok http 3000
# Use the ngrok URL for webhook registration
# https://abc123.ngrok.io/webhook2. Signature Verification Test
javascript
// Test script to verify your implementation
const crypto = require('crypto');
function testSignatureVerification() {
const secret = 'test_secret';
const payload = {
event: 'productOutOfStock',
timestamp: Date.now(),
data: {
productId: '123',
name: 'Test Product'
}
};
// Generate signature
const signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
console.log('Payload:', JSON.stringify(payload));
console.log('Signature:', signature);
// Test your verification function
const isValid = verifyWebhookSignature(
JSON.stringify(payload),
signature,
secret
);
console.log('Verification result:', isValid);
}
testSignatureVerification();3. Load Testing
javascript
// Simple load test for your webhook endpoint
const axios = require('axios');
const crypto = require('crypto');
async function loadTest(url, secret, requests = 100) {
const results = {
success: 0,
failed: 0,
durations: []
};
for (let i = 0; i < requests; i++) {
const payload = {
event: 'test',
timestamp: Date.now(),
data: { test: i }
};
const signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
const start = Date.now();
try {
await axios.post(url, payload, {
headers: {
'X-Webhook-Signature': signature,
'Content-Type': 'application/json'
}
});
results.success++;
results.durations.push(Date.now() - start);
} catch (error) {
results.failed++;
console.error(`Request ${i} failed:`, error.message);
}
}
const avgDuration = results.durations.reduce((a, b) => a + b, 0) / results.durations.length;
console.log('Load test results:');
console.log(`Success: ${results.success}/${requests}`);
console.log(`Failed: ${results.failed}/${requests}`);
console.log(`Average duration: ${avgDuration}ms`);
}
// Run load test
loadTest('http://localhost:3000/webhook', 'your_secret', 100);Security Checklist
Before going live, ensure:
- [ ] ✅ HTTPS is enforced
- [ ] ✅ Signature verification is implemented
- [ ] ✅ Timestamp validation is active
- [ ] ✅ Idempotency handling is in place
- [ ] ✅ Rate limiting is configured
- [ ] ✅ Error handling doesn't expose sensitive data
- [ ] ✅ Logging captures security events
- [ ] ✅ Webhook secret is stored securely
- [ ] ✅ Response time is under 5 seconds
- [ ] ✅ Monitoring alerts are configured
Common Security Issues
❌ Don't Trust Input
javascript
// Bad - Direct database query
app.post('/webhook', async (req, res) => {
await db.query(`UPDATE products SET stock = 0 WHERE id = '${req.body.data.productId}'`);
});
// Good - Validate and sanitize
app.post('/webhook', async (req, res) => {
const productId = req.body.data?.productId;
if (!productId || !productId.match(/^[a-f0-9]{24}$/)) {
return res.status(400).json({ error: 'Invalid product ID' });
}
await db.products.updateOne(
{ _id: productId },
{ $set: { stock: 0 } }
);
});❌ Don't Log Sensitive Data
javascript
// Bad - Logging full webhook
console.log('Received webhook:', JSON.stringify(req.body));
// Good - Log safely
console.log('Received webhook:', {
event: req.body.event,
timestamp: req.body.timestamp,
productId: req.body.data?.productId
// Don't log: signatures, tokens, personal data
});Next Steps
- Implement Best Practices
- Set up Webhook Endpoints
- Configure Monitoring & Alerts