Webhook Security Guide
Comprehensive security guide for webhook integration with the SCORM API.
Table of Contents
- Security Overview
- Signature Verification
- HTTPS Requirements
- Secret Management
- Rate Limiting
- Best Practices
- Common Vulnerabilities
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
- SCORM API creates HMAC-SHA256 signature of request body using your webhook secret
- Signature is sent in
X-Allure-Signatureheader - 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
- Valid Certificate: Use valid SSL certificate from trusted CA
- TLS 1.2+: Require TLS 1.2 or higher
- Certificate Validation: Verify certificate chain
- 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:
- Generate new secret
- Update webhook in SCORM API dashboard with new secret
- Update environment variable
- Test webhook delivery
- 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
- Webhook Setup - How to set up webhooks
- Webhook Events - Event reference
- API Reference - Complete API documentation
Last Updated: 2025-01-15