Webhook Security Guide

Comprehensive security guide for webhook integration with the SCORM API.

Table of Contents

Security Overview

Webhooks are HTTP callbacks that can be vulnerable to:

  • Replay Attacks: Malicious actors resending old webhook events
  • Man-in-the-Middle: Intercepting and modifying webhook payloads
  • Unauthorized Access: Fake webhooks from unauthorized sources
  • DoS Attacks: Overwhelming your endpoint with requests

The SCORM API provides security features to protect against these threats.

Signature Verification

How HMAC Signatures Work

  1. SCORM API creates HMAC-SHA256 signature of request body using your webhook secret
  2. Signature is sent in X-Allure-Signature header
  3. Your endpoint computes the same signature and compares

Implementation

Node.js/TypeScript

import crypto from 'crypto';

export async function POST(request: Request) {
  // Get signature from header
  const signature = request.headers.get('X-Allure-Signature');
  const algorithm = request.headers.get('X-Allure-Signature-Algorithm') || 'sha256';
  
  // Read body as raw text (important!)
  const body = await request.text();
  
  // Get your webhook secret
  const secret = process.env.WEBHOOK_SECRET!;
  
  // Compute expected signature
  const expectedSignature = crypto
    .createHmac(algorithm, secret)
    .update(body)
    .digest('hex');
  
  // Constant-time comparison (prevents timing attacks)
  if (!crypto.timingSafeEqual(
    Buffer.from(signature || ''),
    Buffer.from(expectedSignature)
  )) {
    return new Response('Invalid signature', { status: 401 });
  }
  
  // Signature verified - process webhook
  const event = JSON.parse(body);
  await handleWebhookEvent(event);
  
  return new Response('OK', { status: 200 });
}

Python

import hmac
import hashlib
import json
from flask import request, abort

def verify_webhook_signature(signature, body, secret):
    """Verify webhook signature using constant-time comparison"""
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        body.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected_signature)

@app.route('/webhooks/scorm', methods=['POST'])
def webhook_handler():
    signature = request.headers.get('X-Allure-Signature')
    body = request.get_data(as_text=True)
    secret = os.getenv('WEBHOOK_SECRET')
    
    if not verify_webhook_signature(signature, body, secret):
        abort(401, 'Invalid signature')
    
    event = json.loads(body)
    # Process event
    return 'OK', 200

PHP

<?php
function verifyWebhookSignature($signature, $body, $secret) {
    $expectedSignature = hash_hmac('sha256', $body, $secret);
    return hash_equals($signature, $expectedSignature);
}

$signature = $_SERVER['HTTP_X_ALLURE_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$secret = getenv('WEBHOOK_SECRET');

if (!verifyWebhookSignature($signature, $body, $secret)) {
    http_response_code(401);
    die('Invalid signature');
}

$event = json_decode($body, true);
// Process event
http_response_code(200);
echo 'OK';
?>

Critical: Read Body as Raw Text

Important: Always read the request body as raw text before parsing JSON. Reading as JSON first can modify the body and break signature verification.

// ❌ WRONG - Parsing JSON first breaks signature
const event = await request.json();
const signature = request.headers.get('X-Allure-Signature');
// Signature won't match because body was already parsed

// ✅ CORRECT - Read as text first
const body = await request.text();
const signature = request.headers.get('X-Allure-Signature');
// Verify signature
const event = JSON.parse(body);

HTTPS Requirements

Always Use HTTPS

Never use HTTP for webhook endpoints in production:

  • HTTP traffic can be intercepted
  • Man-in-the-middle attacks are possible
  • Secrets can be exposed

SSL/TLS Best Practices

  1. Valid Certificate: Use valid SSL certificate from trusted CA
  2. TLS 1.2+: Require TLS 1.2 or higher
  3. Certificate Validation: Verify certificate chain
  4. HSTS: Enable HTTP Strict Transport Security

Testing with ngrok

For local development, use ngrok which provides HTTPS:

ngrok http 3000
# Use: https://abc123.ngrok.io/api/webhooks/scorm

Secret Management

Storing Secrets

Never commit secrets to version control:

# ❌ WRONG - In code
const secret = "my-secret-key";

# ✅ CORRECT - Environment variable
const secret = process.env.WEBHOOK_SECRET;

Environment Variables

# .env (never commit this file)
WEBHOOK_SECRET=your-webhook-secret-here

# .env.example (safe to commit)
WEBHOOK_SECRET=your-webhook-secret-here

Secret Rotation

Rotate webhook secrets regularly:

  1. Generate new secret
  2. Update webhook in SCORM API dashboard with new secret
  3. Update environment variable
  4. Test webhook delivery
  5. Revoke old secret

Secret Generation

Generate strong, random secrets:

# Generate 32-byte random secret
openssl rand -hex 32

# Or use Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Rate Limiting

Implement Rate Limiting

Protect your webhook endpoint from DoS attacks:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'),
});

export async function POST(request: Request) {
  // Rate limit by IP
  const ip = request.headers.get('x-forwarded-for') || 'unknown';
  const { success } = await ratelimit.limit(ip);
  
  if (!success) {
    return new Response('Rate limit exceeded', { status: 429 });
  }
  
  // Process webhook
  // ...
}

Whitelist SCORM API IPs

If possible, whitelist SCORM API IP addresses:

const ALLOWED_IPS = [
  '52.1.2.3', // SCORM API production IPs
  '52.4.5.6',
];

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for');
  
  if (!ALLOWED_IPS.includes(ip)) {
    return new Response('Forbidden', { status: 403 });
  }
  
  // Process webhook
}

Best Practices

1. Verify Signature First

Always verify signature before processing:

export async function POST(request: Request) {
  // 1. Verify signature FIRST
  if (!verifySignature(request)) {
    return new Response('Invalid signature', { status: 401 });
  }
  
  // 2. Then process webhook
  const event = await request.json();
  await handleEvent(event);
  
  return new Response('OK', { status: 200 });
}

2. Process Asynchronously

Return 200 immediately, process in background:

export async function POST(request: Request) {
  // Verify signature
  if (!verifySignature(request)) {
    return new Response('Invalid signature', { status: 401 });
  }
  
  const event = await request.json();
  
  // Queue for async processing (don't block response)
  await queueWebhookProcessing(event);
  
  // Return immediately
  return new Response('OK', { status: 200 });
}

3. Implement Idempotency

Handle duplicate deliveries:

const processedDeliveries = new Set<string>();

export async function POST(request: Request) {
  const event = await request.json();
  const deliveryId = event.delivery_id;
  
  // Check if already processed
  if (processedDeliveries.has(deliveryId)) {
    return new Response('Already processed', { status: 200 });
  }
  
  // Process and mark as processed
  await handleEvent(event);
  processedDeliveries.add(deliveryId);
  
  return new Response('OK', { status: 200 });
}

4. Log All Webhooks

Keep audit trail:

export async function POST(request: Request) {
  const event = await request.json();
  
  // Log webhook receipt
  await logWebhook({
    delivery_id: event.delivery_id,
    event_type: event.event,
    received_at: new Date(),
    signature_valid: true,
  });
  
  // Process webhook
  await handleEvent(event);
  
  return new Response('OK', { status: 200 });
}

5. Handle Errors Gracefully

Don't let webhook processing crash your app:

export async function POST(request: Request) {
  try {
    // Verify and process
    await processWebhook(request);
    return new Response('OK', { status: 200 });
  } catch (error) {
    // Log error but return 200 (don't trigger retries for your bugs)
    console.error('Webhook processing error:', error);
    await logError(error);
    return new Response('OK', { status: 200 });
  }
}

Common Vulnerabilities

Vulnerability 1: Missing Signature Verification

Risk: Anyone can send fake webhooks to your endpoint.

Fix: Always verify signatures before processing.

Vulnerability 2: Reading Body as JSON First

Risk: Signature verification fails because body was modified.

Fix: Read body as raw text, verify signature, then parse JSON.

Vulnerability 3: Using HTTP Instead of HTTPS

Risk: Webhooks can be intercepted and modified.

Fix: Always use HTTPS in production.

Vulnerability 4: Weak Secrets

Risk: Secrets can be guessed or brute-forced.

Fix: Use strong, random secrets (32+ bytes).

Vulnerability 5: No Rate Limiting

Risk: DoS attacks can overwhelm your endpoint.

Fix: Implement rate limiting on webhook endpoints.

Security Checklist

  • Signature verification implemented
  • HTTPS enabled in production
  • Secrets stored in environment variables
  • Secrets are strong and random (32+ bytes)
  • Rate limiting implemented
  • Idempotency handling for duplicates
  • Error handling doesn't expose internals
  • Webhook endpoint logs all events
  • Secrets rotated regularly (every 90 days)
  • Webhook endpoint tested with invalid signatures

Related Documentation


Last Updated: 2025-01-15