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:

  1. We create a signature using your webhook secret and the request body
  2. The signature is included in the X-Webhook-Signature header
  3. You verify the signature using the same secret and algorithm
  4. 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: