typescriptintermediate

Webhook Handler with Signature Verification

Process incoming webhooks with HMAC signature verification, replay protection, and idempotency.

typescript
import crypto from 'crypto';
import type { IncomingMessage, ServerResponse } from 'http';

interface WebhookConfig {
  secret: string;
  headerName?: string;
  tolerance?: number; // seconds
}

class WebhookHandler {
  private processedIds = new Set<string>();

  constructor(private config: WebhookConfig) {}

  // Verify HMAC signature (e.g., Stripe, GitHub style)
  verify(payload: string | Buffer, signature: string): boolean {
    const expected = crypto
      .createHmac('sha256', this.config.secret)
      .update(payload)
      .digest('hex');

    const sig = signature.replace('sha256=', '');

    return crypto.timingSafeEqual(
      Buffer.from(sig, 'hex'),
      Buffer.from(expected, 'hex'),
    );
  }

  // Check timestamp to prevent replay attacks
  checkTimestamp(timestamp: number): boolean {
    const tolerance = this.config.tolerance ?? 300;
    const age = Math.abs(Date.now() / 1000 - timestamp);
    return age <= tolerance;
  }

  // Idempotency check
  isProcessed(eventId: string): boolean {
    if (this.processedIds.has(eventId)) return true;
    this.processedIds.add(eventId);
    // Cleanup old IDs periodically in production
    if (this.processedIds.size > 10000) {
      const arr = [...this.processedIds];
      this.processedIds = new Set(arr.slice(-5000));
    }
    return false;
  }

  // Express middleware
  middleware() {
    return async (req: IncomingMessage, res: ServerResponse) => {
      const headerName = this.config.headerName ?? 'x-hub-signature-256';
      const chunks: Buffer[] = [];
      for await (const chunk of req) chunks.push(chunk as Buffer);
      const body = Buffer.concat(chunks);

      const signature = (req.headers as any)[headerName] as string;
      if (!signature || !this.verify(body, signature)) {
        res.writeHead(401);
        res.end(JSON.stringify({ error: 'Invalid signature' }));
        return;
      }

      const payload = JSON.parse(body.toString());

      // Idempotency
      const eventId = (req.headers as any)['x-event-id'] as string;
      if (eventId && this.isProcessed(eventId)) {
        res.writeHead(200);
        res.end(JSON.stringify({ status: 'already_processed' }));
        return;
      }

      // Process webhook
      console.log('Webhook received:', payload);
      res.writeHead(200);
      res.end(JSON.stringify({ status: 'ok' }));
    };
  }
}

// Usage
const handler = new WebhookHandler({ secret: process.env.WEBHOOK_SECRET ?? 'secret' });

// Test verification
const payload = JSON.stringify({ event: 'user.created', data: { id: '123' } });
const sig = crypto.createHmac('sha256', 'secret').update(payload).digest('hex');
console.log('Valid:', handler.verify(payload, sig));

Use Cases

  • GitHub/Stripe webhook processing
  • Secure event ingestion
  • Third-party integration endpoints

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.