Skip to content

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: 3b4c9d8e2f1a6b5c4d3e2f1a6b5c4d3e2f1a6b5c

This 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', 200
php
<?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/webhook

2. 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

The usage of this API is at your own risk. Attivita GmbH is not responsible for any damages or losses.