Tutorial: Webhook Security
Learn how to secure your webhook endpoints with signature verification, implement best practices, and handle webhooks reliably.
🔐 Why Webhook Security Matters
- • Prevent spoofing: Ensure webhooks are genuinely from Sales Webhooks
- • Data integrity: Verify webhook payloads haven't been tampered with
- • Replay protection: Prevent malicious actors from replaying old webhooks
- • Compliance: Meet security requirements for your industry
How Webhook Signatures Work
Sales Webhooks signs every webhook using HMAC-SHA256 with your webhook secret:
- We create a signature using your webhook secret and the request body
- The signature is included in the
X-Webhook-Signature
header - You verify the signature using the same secret and algorithm
- If signatures match, the webhook is authentic
Step 1: Basic Signature Verification
Here's how to verify webhook signatures in different languages:
Node.js / Express
const crypto = require('crypto');
const express = require('express');
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const webhookSecret = process.env.WEBHOOK_SECRET;
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(req.body)
.digest('hex');
// Compare signatures
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
console.error('Invalid webhook signature');
return res.status(401).send('Unauthorized');
}
// Parse and process the webhook
const event = JSON.parse(req.body);
console.log('Webhook verified:', event.type);
// Process the webhook
processWebhook(event);
// Always return 200 OK quickly
res.status(200).send('OK');
});
Python / Flask
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
# Get headers
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
if not signature:
abort(401)
# Get raw body
payload = request.get_data()
# Calculate expected signature
webhook_secret = os.environ['WEBHOOK_SECRET']
expected_signature = hmac.new(
webhook_secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Compare signatures
if not hmac.compare_digest(signature, expected_signature):
print('Invalid webhook signature')
abort(401)
# Parse and process
event = request.get_json()
print(f'Webhook verified: {event["type"]}')
process_webhook(event)
return 'OK', 200
Ruby / Rails
class WebhooksController < ApplicationController
# Skip CSRF protection for webhooks
skip_before_action :verify_authenticity_token
def receive
signature = request.headers['X-Webhook-Signature']
timestamp = request.headers['X-Webhook-Timestamp']
# Calculate expected signature
webhook_secret = ENV['WEBHOOK_SECRET']
payload = request.raw_post
expected_signature = OpenSSL::HMAC.hexdigest(
'SHA256',
webhook_secret,
payload
)
# Compare signatures securely
unless Rack::Utils.secure_compare(signature, expected_signature)
render json: { error: 'Invalid signature' }, status: :unauthorized
return
end
# Parse and process
event = JSON.parse(payload)
Rails.logger.info "Webhook verified: #{event['type']}"
ProcessWebhookJob.perform_later(event)
head :ok
end
end
PHP
<?php
// Get headers
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
// Get raw body
$payload = file_get_contents('php://input');
// Calculate expected signature
$webhookSecret = $_ENV['WEBHOOK_SECRET'];
$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);
// Compare signatures
if (!hash_equals($signature, $expectedSignature)) {
error_log('Invalid webhook signature');
http_response_code(401);
exit('Unauthorized');
}
// Parse and process
$event = json_decode($payload, true);
error_log('Webhook verified: ' . $event['type']);
processWebhook($event);
// Return success
http_response_code(200);
echo 'OK';
Step 2: Implement Replay Protection
Prevent replay attacks by validating the webhook timestamp:
const MAX_WEBHOOK_AGE_MS = 5 * 60 * 1000; // 5 minutes
function verifyWebhook(req) {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Check timestamp to prevent replay attacks
const webhookAge = Date.now() - parseInt(timestamp);
if (webhookAge > MAX_WEBHOOK_AGE_MS) {
throw new Error('Webhook timestamp too old');
}
// Verify signature (as shown above)
const expectedSignature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
throw new Error('Invalid webhook signature');
}
return JSON.parse(req.body);
}
Step 3: Handle Webhooks Idempotently
Webhooks may be delivered more than once. Use the event ID to ensure idempotent processing:
const processedEvents = new Set(); // In production, use Redis or database
async function processWebhook(event) {
// Check if we've already processed this event
if (processedEvents.has(event.id)) {
console.log(`Already processed event ${event.id}`);
return;
}
// Mark as processed (with expiration in production)
processedEvents.add(event.id);
// In production, use Redis with expiration
// await redis.setex(`webhook:${event.id}`, 86400, '1');
try {
// Process based on event type
switch (event.type) {
case 'contact.position_changed':
await handleJobChange(event.data);
break;
case 'contact.post_created':
await handleNewPost(event.data);
break;
// ... handle other event types
}
} catch (error) {
// Remove from processed set so it can be retried
processedEvents.delete(event.id);
throw error;
}
}
Step 4: Implement Robust Error Handling
Handle failures gracefully to ensure webhook delivery:
app.post('/webhook', async (req, res) => {
try {
// 1. Verify webhook immediately
const event = verifyWebhook(req);
// 2. Return 200 OK quickly
res.status(200).send('OK');
// 3. Process asynchronously
setImmediate(async () => {
try {
await processWebhookAsync(event);
} catch (error) {
console.error('Webhook processing failed:', error);
// Log to error tracking service
await errorTracker.report(error, {
event_id: event.id,
event_type: event.type,
retry_count: event.metadata?.retry_count || 0
});
// Optionally queue for retry
await retryQueue.add('webhook', event, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 60000 // Start with 1 minute
}
});
}
});
} catch (error) {
console.error('Webhook verification failed:', error);
// Don't expose internal errors
if (error.message.includes('signature')) {
res.status(401).send('Unauthorized');
} else {
res.status(400).send('Bad Request');
}
}
});
Step 5: Secure Your Endpoint
Additional security measures for your webhook endpoint:
Use HTTPS Only
// Enforce HTTPS in production
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
return res.status(403).send('HTTPS required');
}
next();
});
Implement Rate Limiting
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // Limit each IP to 100 requests per minute
message: 'Too many webhook requests',
// Skip rate limiting for verified webhooks
skip: (req) => {
try {
verifyWebhook(req);
return true;
} catch {
return false;
}
}
});
app.use('/webhook', webhookLimiter);
IP Allowlisting (Optional)
// Sales Webhooks IP ranges (example - check docs for current IPs)
const ALLOWED_IPS = [
'34.123.45.0/24',
'35.234.56.0/24'
];
const ipRangeCheck = require('ip-range-check');
app.use('/webhook', (req, res, next) => {
const clientIp = req.ip || req.connection.remoteAddress;
if (!ipRangeCheck(clientIp, ALLOWED_IPS)) {
return res.status(403).send('Forbidden');
}
next();
});
Step 6: Monitor and Alert
Set up monitoring for webhook security issues:
class WebhookMonitor {
constructor() {
this.metrics = {
received: 0,
verified: 0,
failed_verification: 0,
processed: 0,
failed_processing: 0
};
}
recordReceived() {
this.metrics.received++;
}
recordVerified() {
this.metrics.verified++;
}
recordFailedVerification(reason) {
this.metrics.failed_verification++;
// Alert on suspicious activity
if (this.metrics.failed_verification > 10) {
this.sendAlert({
type: 'security',
severity: 'high',
message: 'Multiple webhook verification failures detected',
details: {
count: this.metrics.failed_verification,
reason: reason
}
});
}
}
async sendAlert(alert) {
// Send to monitoring service
await fetch('https://your-monitoring.com/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(alert)
});
// Also log locally
console.error('SECURITY ALERT:', alert);
}
}
Testing Webhook Security
Test your webhook security implementation:
Test Valid Webhook
# Generate a test signature
PAYLOAD='{"id":"evt_test","type":"test","data":{}}'
SECRET='your_webhook_secret'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
TIMESTAMP=$(date +%s)000
# Send test webhook
curl -X POST https://your-app.com/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIGNATURE" \
-H "X-Webhook-Timestamp: $TIMESTAMP" \
-d "$PAYLOAD"
Test Invalid Signature
# Send with wrong signature - should get 401
curl -X POST https://your-app.com/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: invalid_signature" \
-H "X-Webhook-Timestamp: $TIMESTAMP" \
-d "$PAYLOAD"
Security Checklist
✅ Webhook Security Checklist
Common Security Mistakes
❌ Don't: String comparison for signatures
// WRONG - Vulnerable to timing attacks
if (signature === expectedSignature) { ... }
// RIGHT - Use timing-safe comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { ... }
❌ Don't: Process webhooks synchronously
// WRONG - Can timeout
app.post('/webhook', async (req, res) => {
await processComplexWebhook(req.body); // Takes 30 seconds
res.send('OK'); // Too late!
});
❌ Don't: Expose webhook secrets
// WRONG - Never hardcode secrets
const webhookSecret = 'whsec_abc123';
// RIGHT - Use environment variables
const webhookSecret = process.env.WEBHOOK_SECRET;
Next Steps
Now that your webhooks are secure:
- Implement comprehensive logging for audit trails
- Set up alerts for failed verifications
- Test your implementation with the webhook tester
- Document your webhook endpoint for your team
🛡️ Security Resources
Need help with webhook security? Check out these resources: