Webhook Security
Securing your webhook endpoint is critical. This guide covers how to verify that webhooks are genuinely from ezylegal and protect against common attacks.
Signature Verification
Every webhook request includes a signature in the X-Webhook-Signature header. You should verify this signature before processing any webhook.
How Signatures Work
- ezylegal creates a signature by HMAC-SHA256 signing the request body with your webhook secret
- The signature is included in the
X-Webhook-Signatureheader - Your server recreates the signature and compares it
- If they match, the webhook is authentic
Your Webhook Secret
Find your webhook secret in the Partner Dashboard:
- Go to Settings → Webhooks
- Copy your Webhook Secret
The secret looks like: whsec_abc123def456...
Implementation Examples
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(req, secret) {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const body = req.rawBody; // Must be raw body, not parsed JSON
// Check timestamp to prevent replay attacks (5 minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old');
}
// Create the signed payload
const signedPayload = `${timestamp}.${body}`;
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Compare signatures using timing-safe comparison
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
const receivedBuffer = Buffer.from(signature, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
// Express.js middleware
app.post('/webhooks/ezyfamily',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const isValid = verifyWebhookSignature(
req,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Process webhook...
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error);
res.status(400).send('Webhook error');
}
}
);
Python
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = 'whsec_your_secret_here'
def verify_webhook_signature(payload, signature, timestamp, secret):
# Check timestamp to prevent replay attacks
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
raise ValueError('Webhook timestamp too old')
# Create signed payload
signed_payload = f"{timestamp}.{payload}"
# Calculate expected signature
expected_signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare using timing-safe comparison
return hmac.compare_digest(expected_signature, signature)
@app.route('/webhooks/ezyfamily', methods=['POST'])
def handle_webhook():
payload = request.get_data(as_text=True)
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
try:
if not verify_webhook_signature(
payload, signature, timestamp, WEBHOOK_SECRET
):
abort(401, 'Invalid signature')
event = request.get_json()
# Process webhook...
return 'OK', 200
except Exception as e:
print(f'Webhook error: {e}')
abort(400, 'Webhook error')
PHP
<?php
function verifyWebhookSignature($payload, $signature, $timestamp, $secret) {
// Check timestamp
$currentTime = time();
if (abs($currentTime - intval($timestamp)) > 300) {
throw new Exception('Webhook timestamp too old');
}
// Create signed payload
$signedPayload = $timestamp . '.' . $payload;
// Calculate expected signature
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
// Compare using timing-safe comparison
return hash_equals($expectedSignature, $signature);
}
// Handle webhook
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
try {
if (!verifyWebhookSignature($payload, $signature, $timestamp, $secret)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$event = json_decode($payload, true);
// Process webhook...
http_response_code(200);
echo 'OK';
} catch (Exception $e) {
http_response_code(400);
echo 'Webhook error: ' . $e->getMessage();
}
Ruby
require 'openssl'
require 'sinatra'
WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
def verify_webhook_signature(payload, signature, timestamp, secret)
# Check timestamp
current_time = Time.now.to_i
raise 'Webhook timestamp too old' if (current_time - timestamp.to_i).abs > 300
# Create signed payload
signed_payload = "#{timestamp}.#{payload}"
# Calculate expected signature
expected_signature = OpenSSL::HMAC.hexdigest(
'sha256',
secret,
signed_payload
)
# Compare using timing-safe comparison
Rack::Utils.secure_compare(expected_signature, signature)
end
post '/webhooks/ezyfamily' do
payload = request.body.read
signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']
timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP']
begin
unless verify_webhook_signature(payload, signature, timestamp, WEBHOOK_SECRET)
halt 401, 'Invalid signature'
end
event = JSON.parse(payload)
# Process webhook...
status 200
'OK'
rescue => e
halt 400, "Webhook error: #{e.message}"
end
end
Security Best Practices
1. Always Verify Signatures
Never process a webhook without verifying its signature first. This prevents attackers from sending fake events.
2. Use Raw Request Body
Signature verification requires the exact bytes sent. Parse JSON only after verification:
// Wrong - body is already parsed
app.post('/webhook', express.json(), (req, res) => {
const signature = sign(JSON.stringify(req.body)); // Won't match!
});
// Correct - use raw body
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = sign(req.body); // Will match
const event = JSON.parse(req.body);
});
3. Check Timestamp
Reject webhooks with old timestamps to prevent replay attacks:
const MAX_AGE_SECONDS = 300; // 5 minutes
const timestamp = parseInt(req.headers['x-webhook-timestamp']);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > MAX_AGE_SECONDS) {
throw new Error('Webhook too old');
}
4. Use Timing-Safe Comparison
Always use constant-time comparison to prevent timing attacks:
// Wrong - vulnerable to timing attack
if (calculatedSig === receivedSig) { ... }
// Correct - timing-safe
if (crypto.timingSafeEqual(
Buffer.from(calculatedSig),
Buffer.from(receivedSig)
)) { ... }
5. Use HTTPS
Your webhook endpoint must use HTTPS in production. This ensures:
- Request body is encrypted in transit
- Your server's identity is verified
- Attackers can't intercept signatures
6. Rotate Secrets Periodically
Rotate your webhook secret periodically:
- Generate a new secret in Partner Dashboard
- Update your application to accept both old and new secrets
- After confirming new secret works, remove old secret
Troubleshooting
Signature Mismatch
Common causes:
- Using parsed JSON instead of raw body
- Incorrect secret
- Encoding issues (ensure UTF-8)
- Middleware modifying the request body
Debug Mode
Temporarily log details to diagnose issues:
console.log('Received signature:', signature);
console.log('Timestamp:', timestamp);
console.log('Body length:', body.length);
console.log('Body preview:', body.substring(0, 100));
console.log('Expected signature:', expectedSignature);
Remember to remove debug logging before production!
Related Pages
- Webhooks Overview - Getting started
- Webhook Events - Event types and payloads