Webhook Security Best Practices for ChatGPT Apps: HMAC, Rate Limiting & Replay Prevention

Webhooks power real-time integrations in modern ChatGPT applications, but they also introduce critical security vulnerabilities. Unlike traditional API requests initiated by your application, webhooks are pushed to your server by external services—making them prime targets for replay attacks, man-in-the-middle (MITM) attacks, and request forgery.

A single compromised webhook endpoint can expose customer data, trigger unauthorized actions, or enable attackers to bypass authentication entirely. OpenAI's approval process specifically scrutinizes webhook security, and apps with vulnerable webhook implementations won't pass review.

This comprehensive guide covers production-ready webhook security implementations that satisfy SOC 2, PCI-DSS, and HIPAA compliance requirements. You'll learn defense-in-depth strategies combining HMAC signature validation, timing-safe comparison, IP whitelisting, rate limiting, replay attack prevention, and anomaly detection.

Whether you're integrating Stripe billing webhooks, Twilio communications, or custom MCP server webhooks, these patterns ensure your ChatGPT app remains secure from day one.


1. Webhook Security Threat Landscape

Primary Threat Vectors

Replay Attacks: Attackers intercept legitimate webhook payloads and resend them to trigger duplicate actions (e.g., double-crediting accounts, duplicate order processing).

Man-in-the-Middle (MITM): Without HTTPS or signature verification, attackers can intercept and modify webhook payloads in transit.

Request Forgery: Attackers send fake webhook requests that appear legitimate, bypassing authentication to trigger unauthorized actions.

Denial of Service (DoS): Overwhelming your webhook endpoint with requests to exhaust server resources or database connections.

Timing Attacks: Exploiting non-constant-time string comparisons to extract signature secrets byte-by-byte.

Defense-in-Depth Strategy

Professional webhook security requires layered defenses. No single technique prevents all attacks:

  1. Authentication Layer: HMAC signatures + OAuth tokens
  2. Authorization Layer: IP whitelisting + scope validation
  3. Integrity Layer: Timestamp validation + nonce tracking
  4. Availability Layer: Rate limiting + DDoS mitigation
  5. Monitoring Layer: Security event logging + anomaly detection

This article implements all five layers using production-ready TypeScript and Express.js code.

Compliance Requirements

SOC 2: Requires audit logging, access controls, encryption in transit (HTTPS), and incident response procedures.

PCI-DSS: Mandates HMAC signature validation for payment webhooks, encrypted storage of webhook secrets, and quarterly security audits.

HIPAA: Requires encryption at rest and in transit, audit trails for all PHI access, and business associate agreements with webhook providers.

For HIPAA-compliant ChatGPT apps, webhook security is non-negotiable.


2. Authentication Methods for Webhooks

HMAC Signatures (Recommended)

HMAC (Hash-based Message Authentication Code) is the gold standard for webhook authentication. Services like Stripe, Twilio, GitHub, and Shopify all use HMAC-SHA256 signatures.

How HMAC Works:

  1. Service creates a secret key (e.g., whsec_abc123...)
  2. Service computes HMAC: signature = HMAC-SHA256(secret, payload)
  3. Service sends request with X-Signature header
  4. Your server recomputes HMAC using same secret
  5. Compare signatures using timing-safe comparison

Why HMAC is Superior to API Keys:

  • Tamper-proof: Any modification to payload invalidates signature
  • Replay-resistant: Combined with timestamp validation
  • Secret-safe: Secret never transmitted over network
  • Standards-based: Follows RFC 2104

API Keys vs OAuth Tokens

API Keys:

  • Simple to implement
  • Vulnerable if logged or exposed in URLs
  • No expiration mechanism
  • Suitable for server-to-server communication only

OAuth 2.1 Tokens:

  • Short-lived access tokens (1 hour expiry)
  • Refresh token rotation
  • Scope-based authorization
  • Required for OAuth 2.1 ChatGPT apps

Recommendation: Use HMAC signatures for webhook authentication, OAuth tokens for user-initiated API requests.

Mutual TLS (mTLS)

mTLS requires both client and server to present TLS certificates, ensuring bidirectional authentication.

When to Use mTLS:

  • Enterprise B2B integrations
  • High-security environments (financial services, healthcare)
  • Microservices communication within private networks

Drawbacks:

  • Complex certificate management
  • Requires CA infrastructure
  • Not supported by most webhook providers

For most ChatGPT apps, HMAC signatures provide adequate security without mTLS complexity.


3. HMAC Signature Validation (Production Implementation)

HMAC Signature Generator (TypeScript)

This utility generates HMAC-SHA256 signatures compatible with Stripe, Twilio, and Shopify.

import crypto from 'crypto';

/**
 * Generate HMAC-SHA256 signature for webhook payloads
 *
 * @param payload - Raw request body (Buffer or string)
 * @param secret - Webhook secret key
 * @param timestamp - Unix timestamp (optional, for Stripe-style signatures)
 * @returns Hex-encoded signature
 *
 * @example
 * const signature = generateHmacSignature(
 *   req.body,
 *   process.env.WEBHOOK_SECRET,
 *   Math.floor(Date.now() / 1000)
 * );
 */
export function generateHmacSignature(
  payload: Buffer | string,
  secret: string,
  timestamp?: number
): string {
  // Convert payload to Buffer if string
  const payloadBuffer = Buffer.isBuffer(payload)
    ? payload
    : Buffer.from(payload, 'utf-8');

  // Create signed payload (Stripe format: timestamp.payload)
  const signedPayload = timestamp
    ? Buffer.concat([
        Buffer.from(`${timestamp}.`, 'utf-8'),
        payloadBuffer
      ])
    : payloadBuffer;

  // Compute HMAC-SHA256
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return signature;
}

/**
 * Generate Stripe-compatible signature header
 *
 * @example
 * const header = generateStripeSignatureHeader(
 *   req.body,
 *   process.env.STRIPE_WEBHOOK_SECRET
 * );
 * // Returns: "t=1234567890,v1=abc123...,v0=def456..."
 */
export function generateStripeSignatureHeader(
  payload: Buffer | string,
  secret: string
): string {
  const timestamp = Math.floor(Date.now() / 1000);
  const v1Signature = generateHmacSignature(payload, secret, timestamp);

  // Stripe includes both v1 (current) and v0 (legacy) schemes
  return `t=${timestamp},v1=${v1Signature}`;
}

/**
 * Generate Twilio-compatible signature
 *
 * Twilio appends request parameters to URL before signing
 *
 * @example
 * const signature = generateTwilioSignature(
 *   'https://api.example.com/webhooks/twilio',
 *   { From: '+1234567890', Body: 'Hello' },
 *   process.env.TWILIO_AUTH_TOKEN
 * );
 */
export function generateTwilioSignature(
  url: string,
  params: Record<string, string>,
  authToken: string
): string {
  // Sort parameters alphabetically
  const sortedKeys = Object.keys(params).sort();

  // Build signed string: URL + sorted params
  const signedString = sortedKeys.reduce(
    (acc, key) => acc + key + params[key],
    url
  );

  // Compute HMAC-SHA1 (Twilio uses SHA1, not SHA256)
  const signature = crypto
    .createHmac('sha1', authToken)
    .update(Buffer.from(signedString, 'utf-8'))
    .digest('base64');

  return signature;
}

HMAC Signature Validator (Express Middleware)

This middleware validates incoming webhook signatures before processing requests.

import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';

/**
 * Express middleware for webhook HMAC signature validation
 *
 * Supports multiple signature schemes:
 * - Stripe (X-Stripe-Signature)
 * - Twilio (X-Twilio-Signature)
 * - GitHub (X-Hub-Signature-256)
 * - Shopify (X-Shopify-Hmac-SHA256)
 *
 * @example
 * app.post('/webhooks/stripe',
 *   express.raw({ type: 'application/json' }),
 *   validateWebhookSignature({
 *     provider: 'stripe',
 *     secret: process.env.STRIPE_WEBHOOK_SECRET,
 *     timestampTolerance: 300 // 5 minutes
 *   }),
 *   handleStripeWebhook
 * );
 */
export interface WebhookSignatureConfig {
  provider: 'stripe' | 'twilio' | 'github' | 'shopify' | 'custom';
  secret: string;
  timestampTolerance?: number; // Seconds (default: 300)
  headerName?: string; // For custom providers
  algorithm?: 'sha256' | 'sha1'; // Default: sha256
}

export function validateWebhookSignature(config: WebhookSignatureConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      // Extract signature from headers
      const signature = extractSignature(req, config);
      if (!signature) {
        return res.status(401).json({
          error: 'Missing webhook signature',
          provider: config.provider
        });
      }

      // Extract timestamp (if applicable)
      const timestamp = extractTimestamp(req, config);

      // Validate timestamp freshness (prevent replay attacks)
      if (timestamp && config.timestampTolerance) {
        const age = Math.abs(Date.now() / 1000 - timestamp);
        if (age > config.timestampTolerance) {
          return res.status(401).json({
            error: 'Webhook timestamp too old',
            age_seconds: age,
            max_age_seconds: config.timestampTolerance
          });
        }
      }

      // Compute expected signature
      const payload = req.body; // Must be raw Buffer
      const expectedSignature = computeExpectedSignature(
        payload,
        config,
        timestamp
      );

      // Timing-safe comparison (prevents timing attacks)
      const isValid = timingSafeCompare(signature, expectedSignature);

      if (!isValid) {
        // Log failed validation (security monitoring)
        console.error('[SECURITY] Invalid webhook signature', {
          provider: config.provider,
          ip: req.ip,
          path: req.path,
          timestamp: new Date().toISOString()
        });

        return res.status(401).json({
          error: 'Invalid webhook signature',
          provider: config.provider
        });
      }

      // Signature valid - proceed to handler
      next();

    } catch (error) {
      console.error('[SECURITY] Webhook signature validation error', error);
      return res.status(500).json({
        error: 'Webhook signature validation failed'
      });
    }
  };
}

function extractSignature(req: Request, config: WebhookSignatureConfig): string | null {
  const headerMap: Record<string, string> = {
    stripe: 'stripe-signature',
    twilio: 'x-twilio-signature',
    github: 'x-hub-signature-256',
    shopify: 'x-shopify-hmac-sha256'
  };

  const headerName = config.headerName || headerMap[config.provider];
  const headerValue = req.headers[headerName] as string;

  if (!headerValue) return null;

  // Stripe format: "t=123,v1=abc,v0=def"
  if (config.provider === 'stripe') {
    const match = headerValue.match(/v1=([a-f0-9]+)/);
    return match ? match[1] : null;
  }

  return headerValue;
}

function extractTimestamp(req: Request, config: WebhookSignatureConfig): number | null {
  if (config.provider === 'stripe') {
    const sig = req.headers['stripe-signature'] as string;
    const match = sig?.match(/t=(\d+)/);
    return match ? parseInt(match[1], 10) : null;
  }

  return null;
}

function computeExpectedSignature(
  payload: Buffer,
  config: WebhookSignatureConfig,
  timestamp?: number | null
): string {
  const algorithm = config.algorithm || 'sha256';

  // Stripe: sign "timestamp.payload"
  if (config.provider === 'stripe' && timestamp) {
    const signedPayload = Buffer.concat([
      Buffer.from(`${timestamp}.`, 'utf-8'),
      payload
    ]);

    return crypto
      .createHmac(algorithm, config.secret)
      .update(signedPayload)
      .digest('hex');
  }

  // GitHub: "sha256=<signature>"
  if (config.provider === 'github') {
    return crypto
      .createHmac(algorithm, config.secret)
      .update(payload)
      .digest('hex');
  }

  // Default: simple HMAC
  return crypto
    .createHmac(algorithm, config.secret)
    .update(payload)
    .digest('hex');
}

Timing-Safe Comparison (Prevents Timing Attacks)

NEVER use === or == to compare signatures. Use constant-time comparison to prevent timing attacks.

import crypto from 'crypto';

/**
 * Timing-safe string comparison
 *
 * Prevents timing attacks by ensuring comparison takes constant time
 * regardless of where strings differ.
 *
 * @example
 * const isValid = timingSafeCompare(
 *   receivedSignature,
 *   expectedSignature
 * );
 */
export function timingSafeCompare(a: string, b: string): boolean {
  // Convert to Buffers
  const bufferA = Buffer.from(a, 'utf-8');
  const bufferB = Buffer.from(b, 'utf-8');

  // Length check (fail fast for obviously different lengths)
  if (bufferA.length !== bufferB.length) {
    // Still perform timing-safe comparison on dummy values
    // to prevent timing leak of length difference position
    crypto.timingSafeEqual(
      Buffer.alloc(32),
      Buffer.alloc(32)
    );
    return false;
  }

  // Constant-time comparison
  try {
    return crypto.timingSafeEqual(bufferA, bufferB);
  } catch (error) {
    return false;
  }
}

/**
 * Timing-safe hex comparison (for signatures)
 *
 * @example
 * const isValid = timingSafeCompareHex(
 *   'abc123...',
 *   'abc123...'
 * );
 */
export function timingSafeCompareHex(a: string, b: string): boolean {
  // Normalize to lowercase
  const normalizedA = a.toLowerCase();
  const normalizedB = b.toLowerCase();

  return timingSafeCompare(normalizedA, normalizedB);
}

Why Timing Attacks Matter: Without constant-time comparison, attackers can measure response times to determine where signatures differ, extracting secrets byte-by-byte.


4. IP Whitelisting & Rate Limiting

IP Whitelist Middleware (Express)

Restrict webhook access to known IP addresses from trusted services.

import { Request, Response, NextFunction } from 'express';
import { isIPv4, isIPv6 } from 'net';

/**
 * IP whitelist middleware for webhook endpoints
 *
 * @example
 * app.post('/webhooks/stripe',
 *   ipWhitelist({
 *     allowedIPs: [
 *       '3.18.12.63',     // Stripe IP range
 *       '3.130.192.231',
 *       '13.235.14.237',
 *       '::ffff:3.18.12.63' // IPv6-mapped IPv4
 *     ],
 *     allowPrivateIPs: false,
 *     logBlocked: true
 *   }),
 *   handleStripeWebhook
 * );
 */
export interface IPWhitelistConfig {
  allowedIPs: string[];
  allowPrivateIPs?: boolean; // Allow 127.0.0.1, 10.x.x.x, etc.
  logBlocked?: boolean;
}

export function ipWhitelist(config: IPWhitelistConfig) {
  return (req: Request, res: Response, next: NextFunction) => {
    // Extract client IP (handle proxies)
    const clientIP = getClientIP(req);

    // Check private IP ranges
    if (!config.allowPrivateIPs && isPrivateIP(clientIP)) {
      if (config.logBlocked) {
        console.warn('[SECURITY] Blocked private IP', {
          ip: clientIP,
          path: req.path,
          timestamp: new Date().toISOString()
        });
      }

      return res.status(403).json({
        error: 'Access denied',
        reason: 'Private IP not allowed'
      });
    }

    // Check whitelist
    const isAllowed = config.allowedIPs.some(allowedIP =>
      ipMatches(clientIP, allowedIP)
    );

    if (!isAllowed) {
      if (config.logBlocked) {
        console.warn('[SECURITY] Blocked IP not in whitelist', {
          ip: clientIP,
          path: req.path,
          timestamp: new Date().toISOString()
        });
      }

      return res.status(403).json({
        error: 'Access denied',
        reason: 'IP not whitelisted'
      });
    }

    next();
  };
}

function getClientIP(req: Request): string {
  // Check X-Forwarded-For (reverse proxy)
  const forwardedFor = req.headers['x-forwarded-for'];
  if (forwardedFor) {
    const ips = (forwardedFor as string).split(',');
    return ips[0].trim();
  }

  // Check X-Real-IP (nginx)
  const realIP = req.headers['x-real-ip'];
  if (realIP) {
    return realIP as string;
  }

  // Fallback to socket
  return req.socket.remoteAddress || req.ip;
}

function isPrivateIP(ip: string): boolean {
  // Remove IPv6 prefix
  const normalizedIP = ip.replace(/^::ffff:/, '');

  // Check private ranges
  return (
    normalizedIP === '127.0.0.1' ||
    normalizedIP.startsWith('10.') ||
    normalizedIP.startsWith('192.168.') ||
    /^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalizedIP)
  );
}

function ipMatches(clientIP: string, allowedIP: string): boolean {
  // Normalize IPs
  const normalizedClient = clientIP.replace(/^::ffff:/, '');
  const normalizedAllowed = allowedIP.replace(/^::ffff:/, '');

  return normalizedClient === normalizedAllowed;
}

Rate Limiter (Redis-Backed)

Prevent abuse with Redis-backed rate limiting.

import { Request, Response, NextFunction } from 'express';
import { createClient, RedisClientType } from 'redis';

/**
 * Redis-backed rate limiter for webhook endpoints
 *
 * Implements sliding window algorithm for precise rate limiting
 *
 * @example
 * const limiter = createRateLimiter({
 *   redis: redisClient,
 *   windowMs: 60000, // 1 minute
 *   maxRequests: 100,
 *   keyPrefix: 'webhook:ratelimit'
 * });
 *
 * app.post('/webhooks/stripe', limiter, handleStripeWebhook);
 */
export interface RateLimiterConfig {
  redis: RedisClientType;
  windowMs: number; // Time window in milliseconds
  maxRequests: number; // Max requests per window
  keyPrefix?: string;
  skipSuccessfulRequests?: boolean; // Only count failed requests
}

export function createRateLimiter(config: RateLimiterConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = buildRateLimitKey(req, config.keyPrefix);
    const now = Date.now();
    const windowStart = now - config.windowMs;

    try {
      // Use Redis sorted set for sliding window
      const multi = config.redis.multi();

      // Remove old entries
      multi.zRemRangeByScore(key, 0, windowStart);

      // Add current request
      multi.zAdd(key, { score: now, value: `${now}` });

      // Count requests in window
      multi.zCount(key, windowStart, now);

      // Set expiry
      multi.expire(key, Math.ceil(config.windowMs / 1000));

      const results = await multi.exec();
      const requestCount = results[2] as number;

      // Set rate limit headers
      res.setHeader('X-RateLimit-Limit', config.maxRequests);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, config.maxRequests - requestCount));
      res.setHeader('X-RateLimit-Reset', new Date(now + config.windowMs).toISOString());

      // Check limit
      if (requestCount > config.maxRequests) {
        console.warn('[SECURITY] Rate limit exceeded', {
          ip: req.ip,
          path: req.path,
          count: requestCount,
          limit: config.maxRequests
        });

        return res.status(429).json({
          error: 'Too many requests',
          retryAfter: Math.ceil(config.windowMs / 1000)
        });
      }

      next();

    } catch (error) {
      console.error('[ERROR] Rate limiter error', error);
      // Fail open (allow request if Redis fails)
      next();
    }
  };
}

function buildRateLimitKey(req: Request, prefix = 'ratelimit'): string {
  const ip = req.ip || req.socket.remoteAddress;
  const path = req.path;
  return `${prefix}:${ip}:${path}`;
}

DDoS Protection (TypeScript)

Detect and block DDoS attacks using adaptive thresholds.

/**
 * DDoS protection middleware with adaptive thresholds
 *
 * Monitors request patterns and automatically blocks suspicious IPs
 *
 * @example
 * const ddosProtection = createDDoSProtection({
 *   redis: redisClient,
 *   burstThreshold: 50, // 50 requests in burst window
 *   burstWindowMs: 1000, // 1 second burst window
 *   blockDurationMs: 300000 // Block for 5 minutes
 * });
 */
export interface DDoSProtectionConfig {
  redis: RedisClientType;
  burstThreshold: number;
  burstWindowMs: number;
  blockDurationMs: number;
}

export function createDDoSProtection(config: DDoSProtectionConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const ip = req.ip || req.socket.remoteAddress;
    const blockKey = `ddos:block:${ip}`;
    const burstKey = `ddos:burst:${ip}`;

    try {
      // Check if IP is blocked
      const isBlocked = await config.redis.get(blockKey);
      if (isBlocked) {
        return res.status(429).json({
          error: 'IP temporarily blocked',
          reason: 'DDoS protection triggered'
        });
      }

      // Increment burst counter
      const burstCount = await config.redis.incr(burstKey);
      await config.redis.pExpire(burstKey, config.burstWindowMs);

      // Check burst threshold
      if (burstCount > config.burstThreshold) {
        // Block IP
        await config.redis.set(
          blockKey,
          '1',
          { PX: config.blockDurationMs }
        );

        console.error('[SECURITY] DDoS protection triggered', {
          ip,
          burstCount,
          threshold: config.burstThreshold
        });

        return res.status(429).json({
          error: 'IP blocked',
          reason: 'Excessive request rate detected'
        });
      }

      next();

    } catch (error) {
      console.error('[ERROR] DDoS protection error', error);
      next(); // Fail open
    }
  };
}

5. Replay Attack Prevention

Timestamp Validator (TypeScript)

Reject webhook requests with stale timestamps.

/**
 * Timestamp validator middleware
 *
 * Prevents replay attacks by rejecting requests with old timestamps
 *
 * @example
 * app.post('/webhooks/custom',
 *   validateTimestamp({
 *     headerName: 'X-Timestamp',
 *     toleranceSeconds: 300, // 5 minutes
 *     format: 'unix'
 *   }),
 *   handleCustomWebhook
 * );
 */
export interface TimestampValidatorConfig {
  headerName: string;
  toleranceSeconds: number;
  format?: 'unix' | 'iso8601';
}

export function validateTimestamp(config: TimestampValidatorConfig) {
  return (req: Request, res: Response, next: NextFunction) => {
    const timestampHeader = req.headers[config.headerName.toLowerCase()] as string;

    if (!timestampHeader) {
      return res.status(401).json({
        error: 'Missing timestamp header',
        header: config.headerName
      });
    }

    // Parse timestamp
    let timestamp: number;
    if (config.format === 'iso8601') {
      timestamp = new Date(timestampHeader).getTime() / 1000;
    } else {
      timestamp = parseInt(timestampHeader, 10);
    }

    if (isNaN(timestamp)) {
      return res.status(400).json({
        error: 'Invalid timestamp format'
      });
    }

    // Check freshness
    const now = Math.floor(Date.now() / 1000);
    const age = Math.abs(now - timestamp);

    if (age > config.toleranceSeconds) {
      console.warn('[SECURITY] Stale timestamp rejected', {
        age,
        tolerance: config.toleranceSeconds,
        ip: req.ip
      });

      return res.status(401).json({
        error: 'Timestamp too old',
        age_seconds: age,
        max_age_seconds: config.toleranceSeconds
      });
    }

    next();
  };
}

Nonce Tracker (Redis-Backed)

Track request nonces to prevent duplicate processing.

/**
 * Nonce tracker middleware (prevents duplicate requests)
 *
 * Uses Redis to track request nonces with TTL matching timestamp tolerance
 *
 * @example
 * app.post('/webhooks/idempotent',
 *   trackNonce({
 *     redis: redisClient,
 *     headerName: 'X-Request-ID',
 *     ttlSeconds: 300
 *   }),
 *   handleIdempotentWebhook
 * );
 */
export interface NonceTrackerConfig {
  redis: RedisClientType;
  headerName: string;
  ttlSeconds: number;
}

export function trackNonce(config: NonceTrackerConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const nonce = req.headers[config.headerName.toLowerCase()] as string;

    if (!nonce) {
      return res.status(401).json({
        error: 'Missing nonce header',
        header: config.headerName
      });
    }

    const key = `nonce:${nonce}`;

    try {
      // Check if nonce exists (replay attack)
      const exists = await config.redis.exists(key);
      if (exists) {
        console.warn('[SECURITY] Duplicate nonce detected (replay attack)', {
          nonce,
          ip: req.ip,
          path: req.path
        });

        return res.status(409).json({
          error: 'Duplicate request',
          reason: 'Nonce already processed'
        });
      }

      // Store nonce
      await config.redis.set(key, '1', { EX: config.ttlSeconds });

      next();

    } catch (error) {
      console.error('[ERROR] Nonce tracker error', error);
      next(); // Fail open
    }
  };
}

6. Monitoring & Alerting

Security Event Logger (TypeScript)

Log all security-related events for audit and forensics.

import winston from 'winston';

/**
 * Security event logger
 *
 * Logs webhook security events with structured data for SIEM integration
 */
export const securityLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  defaultMeta: { service: 'webhook-security' },
  transports: [
    // Write to security.log
    new winston.transports.File({
      filename: 'logs/security.log',
      level: 'warn'
    }),
    // Write all events to combined.log
    new winston.transports.File({
      filename: 'logs/combined.log'
    })
  ]
});

/**
 * Log security event
 *
 * @example
 * logSecurityEvent({
 *   event: 'webhook.signature.invalid',
 *   severity: 'high',
 *   ip: req.ip,
 *   path: req.path,
 *   details: { provider: 'stripe' }
 * });
 */
export interface SecurityEvent {
  event: string;
  severity: 'low' | 'medium' | 'high' | 'critical';
  ip: string;
  path: string;
  details?: Record<string, any>;
}

export function logSecurityEvent(event: SecurityEvent) {
  securityLogger.warn({
    ...event,
    timestamp: new Date().toISOString(),
    environment: process.env.NODE_ENV
  });

  // Trigger alerts for high/critical events
  if (event.severity === 'high' || event.severity === 'critical') {
    sendSecurityAlert(event);
  }
}

async function sendSecurityAlert(event: SecurityEvent) {
  // Integrate with PagerDuty, Slack, email, etc.
  console.error('[SECURITY ALERT]', event);

  // Example: Send to Slack webhook
  // await fetch(process.env.SLACK_WEBHOOK_URL, {
  //   method: 'POST',
  //   headers: { 'Content-Type': 'application/json' },
  //   body: JSON.stringify({
  //     text: `🚨 Security Alert: ${event.event}`,
  //     attachments: [{ text: JSON.stringify(event, null, 2) }]
  //   })
  // });
}

Anomaly Detection

Monitor webhook patterns for suspicious activity:

  • Burst Detection: 10+ requests from single IP in 1 second
  • Geographic Anomalies: Requests from unexpected countries
  • Signature Failures: 5+ failed validations from same IP
  • Payload Anomalies: Unusually large payloads (>1MB)

Integrate with SIEM platforms (Splunk, Datadog) for advanced threat detection.


7. Production Deployment Checklist

✅ Authentication

  • HMAC signature validation enabled
  • Timing-safe comparison implemented
  • Webhook secrets stored in environment variables (not hardcoded)

✅ Authorization

  • IP whitelist configured for known providers
  • Rate limiting enabled (100 requests/minute recommended)
  • DDoS protection active

✅ Integrity

  • Timestamp validation enabled (5-minute tolerance)
  • Nonce tracking implemented
  • Request payload size limits enforced (<10MB)

✅ Availability

  • Redis for distributed rate limiting
  • Health check endpoint (/health)
  • Graceful degradation if Redis fails

✅ Monitoring

  • Security event logging enabled
  • Alerting configured for high-severity events
  • Weekly review of security logs

✅ Compliance

  • HTTPS enforced (no HTTP)
  • Audit trail retention (90 days minimum)
  • Incident response plan documented

For ChatGPT app testing, verify all security controls in staging before production deployment.


8. Conclusion: Defense-in-Depth Wins

Webhook security isn't optional—it's the foundation of trustworthy ChatGPT applications. Apps that skip HMAC validation, ignore rate limiting, or neglect replay prevention face inevitable breaches.

This guide provided production-ready implementations of seven critical security controls:

  1. HMAC Signature Validation: Prevents request forgery
  2. Timing-Safe Comparison: Prevents timing attacks
  3. IP Whitelisting: Restricts access to trusted sources
  4. Rate Limiting: Prevents abuse and DDoS
  5. Timestamp Validation: Prevents replay attacks
  6. Nonce Tracking: Ensures idempotency
  7. Security Monitoring: Detects threats in real-time

Implement these patterns in your MCP server webhooks, integrate with OAuth 2.1 authentication, and follow SaaS integration best practices to build ChatGPT apps that pass OpenAI approval and earn customer trust.

Security is not a feature—it's the foundation.


Start Building Secure ChatGPT Apps Today

Ready to deploy production-ready webhook security? MakeAIHQ generates HMAC-validated, rate-limited webhook endpoints automatically—no coding required.

Start Free Trial →

Build your first secure ChatGPT app in 48 hours with enterprise-grade security baked in.