Webhook Delivery Tutorial · Module 09 of 10

Security & Compliance

You're posting to customer URLs with sensitive data. Sign requests, prevent SSRF, manage secrets, audit logs. By the end, customers can verify they're receiving legitimate webhooks.

~4–6 hrsAdvancedSecurity focus
← Back to Module 09 overview
What You'll Have at the End

Definition of Done

  • Every webhook POST includes X-Webhook-Signature: sha256=... header.
  • SSRF prevention: block private IPs, metadata endpoints, file scheme.
  • No secrets in code: database passwords, API keys in env or secrets manager.
  • Audit logs: who created webhooks, who retried DLQ items, quota changes.
  • Threat model document identifying assets, actors, attack vectors, mitigations.
The Steps

Build It

STEP 1

Implement HMAC signing for webhooks

Update src/worker/deliveryWorker.ts:

import crypto from 'crypto';

function signPayload(payload: any, secret: string): string {
  const body = JSON.stringify(payload);
  const timestamp = Date.now() / 1000; // Unix timestamp
  const toSign = `${body}.${timestamp}`;

  return crypto
    .createHmac('sha256', secret)
    .update(toSign)
    .digest('hex');
}

// In delivery worker:
const signature = signPayload(payload, webhook.secret);

await axios.post(webhook_url, payload, {
  headers: {
    'Content-Type': 'application/json',
    'X-Webhook-Signature': `sha256=${signature}`,
    'X-Webhook-Timestamp': String(Date.now() / 1000),
    'X-Delivery-ID': delivery_id
  }
});
✓ Verify: Every webhook POST includes signature header. Customers can verify it using the secret.
STEP 2

Implement SSRF prevention

Create src/security/ssrf.ts:

import { isIP } from 'net';

const PRIVATE_RANGES = [
  '127.0.0.1',
  '::1',
  '0.0.0.0',
  '169.254.0.0/16',     // Link-local
  '10.0.0.0/8',         // Private
  '172.16.0.0/12',      // Private
  '192.168.0.0/16'      // Private
];

const METADATA_HOSTS = [
  'metadata.google.internal',
  '169.254.169.254',
  'http://169.254.169.254',
  'http://metadata.google.internal'
];

export function isSafeURL(url: string): boolean {
  try {
    const parsed = new URL(url);

    // Block dangerous schemes
    if (['file:', 'data:', 'javascript:', 'vbscript:'].includes(parsed.protocol)) {
      return false;
    }

    // Block metadata endpoints
    if (METADATA_HOSTS.some(host => url.includes(host))) {
      return false;
    }

    // Resolve hostname and check IP
    const hostname = parsed.hostname;
    if (isIP(hostname)) {
      // Check if it's in a private range
      if (PRIVATE_RANGES.some(range => {
        if (range.includes('/')) {
          // CIDR check would go here
          return false;
        }
        return hostname === range;
      })) {
        return false;
      }
    }

    return true;
  } catch (err) {
    return false;
  }
}

export default isSafeURL;

Use in webhook registration:

import isSafeURL from '../security/ssrf';

if (!isSafeURL(req.body.url)) {
  return res.status(400).json({ error: 'Webhook URL is not allowed (private IP or metadata endpoint)' });
}
✓ Verify: Registering webhook to 127.0.0.1 or metadata endpoint is rejected.
STEP 3

Move secrets to environment variables

Create .env.example:

DATABASE_URL=postgresql://user:password@localhost:5432/webhook_service
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key-here
LOG_LEVEL=info

Use in code:

const dbUrl = process.env.DATABASE_URL || 'postgres://...';
const redisUrl = process.env.REDIS_URL || 'redis://...';

if (!process.env.JWT_SECRET) {
  throw new Error('JWT_SECRET environment variable is required');
}

Never commit .env (add to .gitignore):

.env
.env.local
secrets.json
✓ Verify: .env is in .gitignore. .env.example exists with dummy values.
STEP 4

Implement audit logging

Create src/db/audit.ts:

import pool from './pool';
import logger from '../logger';

export async function auditLog(action: string, details: any, userId?: string) {
  try {
    await pool.query(`
      INSERT INTO audit_logs (action, details, user_id, timestamp)
      VALUES ($1, $2, $3, NOW())
    `, [action, JSON.stringify(details), userId]);

    logger.info({ action, details, userId, type: 'audit' });
  } catch (err) {
    logger.error({ err, action, details }, 'Failed to log audit event');
  }
}

// Usage:
await auditLog('webhook_created', { webhook_id, tenant_id, url });
await auditLog('dlq_retry', { delivery_id, attempts });
await auditLog('quota_exceeded', { tenant_id, limit, current });

Create audit_logs table in migrations:

CREATE TABLE IF NOT EXISTS audit_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  action TEXT NOT NULL,
  details JSONB,
  user_id UUID,
  timestamp TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_audit_action ON audit_logs(action);
CREATE INDEX idx_audit_timestamp ON audit_logs(timestamp);
✓ Verify: Audit log entries appear for webhook creation, DLQ retries, etc.
STEP 5

Write threat model

Create docs/threat-model.md:

# Threat Model — Webhook Delivery Service

## Assets
- Customer URLs (endpoints we POST to)
- Customer secrets (used for HMAC signing)
- Event payloads (may contain sensitive data)
- Delivery state (audit trail)

## Threats & Mitigations

### 1. Customer sees other customer's events
**Threat:** Query injection, authorization bypass
**Mitigation:** Parameterized queries, tenant isolation, audit logs

### 2. SSRF attack via webhook URL
**Threat:** Attacker registers webhook to internal IP, accesses metadata endpoints
**Mitigation:** Block private IPs, metadata endpoints, file scheme

### 3. Replay attacks on webhooks
**Threat:** Attacker replays a webhook POST, duplicating side effects
**Mitigation:** Timestamp in signature, idempotency keys, customer deduplication

### 4. Secrets in git/logs
**Threat:** API keys, database passwords leaked
**Mitigation:** Environment variables, never log secrets, audit who accesses them

### 5. DoS via webhook registration
**Threat:** Attacker registers 1M webhooks, crashes system
**Mitigation:** Rate limiting, quotas, per-tenant limits
✓ Verify: Threat model is committed and covers key attack vectors.
STEP 6

Commit security work

git add -A
git commit -m "security: add HMAC signing, SSRF prevention, and audit logging

- HMAC-SHA256 signing for all webhook POSTs
- SSRF prevention: block private IPs and metadata endpoints
- Move secrets to environment variables (not code)
- Audit logging: track webhook creation, DLQ retries, quota changes
- Threat model identifying assets, risks, and mitigations"
git push origin main
✓ Verify: git log --oneline shows your commit.
Next Steps

Ready for Module 10?

Your system is now secure and well-documented. The final module is the capstone: deploy, write runbooks, and document everything. Head to Module 10.