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

  1. ezylegal creates a signature by HMAC-SHA256 signing the request body with your webhook secret
  2. The signature is included in the X-Webhook-Signature header
  3. Your server recreates the signature and compares it
  4. If they match, the webhook is authentic

Your Webhook Secret

Find your webhook secret in the Partner Dashboard:

  1. Go to SettingsWebhooks
  2. 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:

  1. Generate a new secret in Partner Dashboard
  2. Update your application to accept both old and new secrets
  3. 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