Webhook Security Checklist: Protect Your Integrations from Replay Attacks

By Hari Prakash · 2026-02-21 · 7 min read

Webhooks power the connective tissue of modern applications. Stripe sends payment confirmations, GitHub triggers CI pipelines, Twilio delivers SMS receipts — all through HTTP callbacks to your server. But here's the problem: a webhook endpoint is a publicly accessible URL that accepts POST requests from the internet. Without proper webhook security, anyone who discovers that URL can forge requests, replay legitimate ones, or flood your system with garbage data that corrupts your application state.

Most webhook integrations ship with security features built in. Stripe signs every webhook. GitHub includes a secret-based signature. Slack sends verification tokens. But these protections only work if you actually validate them on your end — and a surprising number of production systems don't. This checklist covers every layer of webhook security you need to implement, with working Node.js code for each one.

1. Validate HMAC Signatures on Every Request

HMAC (Hash-based Message Authentication Code) signature validation is the foundation of webhook security. The sending service computes a hash of the request body using a shared secret, then includes that hash in a header. Your server recalculates the hash using the same secret and compares. If they match, the request is authentic. If they don't, someone is forging requests.

Here's how Stripe's webhook signature validation works in Node.js:

const crypto = require('crypto');

function verifyStripeSignature(payload, sigHeader, secret) {
  const elements = sigHeader.split(',');
  const timestamp = elements
    .find(e => e.startsWith('t='))
    ?.split('=')[1];
  const signature = elements
    .find(e => e.startsWith('v1='))
    ?.split('=')[1];

  if (!timestamp || !signature) {
    throw new Error('Missing signature elements');
  }

  const signedPayload = timestamp + '.' + payload;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Constant-time comparison prevents timing attacks
  const expected = Buffer.from(expectedSig, 'utf8');
  const received = Buffer.from(signature, 'utf8');

  if (expected.length !== received.length ||
      !crypto.timingSafeEqual(expected, received)) {
    throw new Error('Invalid signature');
  }

  return { timestamp: parseInt(timestamp, 10) };
}

Two details matter here. First, use crypto.timingSafeEqual() instead of === for comparing signatures. String comparison with === short-circuits on the first mismatched character, which leaks information about the expected signature through response timing differences. Constant-time comparison prevents this side-channel attack. Second, compute the HMAC on the raw request body — not a parsed-and-reserialized version. JSON parsing and re-stringifying can change whitespace and key ordering, which changes the hash.

2. Check Timestamps to Prevent Replay Attacks

HMAC signatures prove a request is authentic, but they don't prevent an attacker from capturing a legitimate signed request and replaying it later. If an attacker intercepts a valid webhook for a $100 payment, they could replay it 50 times and credit an account with $5,000.

Timestamp verification closes this gap. Most webhook providers include a timestamp in the signed payload. Your server should reject any request where the timestamp is too far from the current time:

function verifyTimestamp(webhookTimestamp, toleranceSec = 300) {
  const now = Math.floor(Date.now() / 1000);
  const diff = Math.abs(now - webhookTimestamp);

  if (diff > toleranceSec) {
    throw new Error(
      'Webhook timestamp too old: ' + diff + 's drift (max ' + toleranceSec + 's)'
    );
  }
}

A tolerance window of 5 minutes (300 seconds) is standard. This accounts for network latency and minor clock drift between servers, while making replayed requests from hours or days ago impossible. Stripe, GitHub, and Shopify all include timestamps in their webhook signatures for exactly this reason.

For additional protection, track processed webhook IDs in a cache (Redis works well here) and reject duplicates within the tolerance window. This handles the edge case where an attacker replays a webhook within the 5-minute window:

const processedIds = new Map(); // Use Redis in production

function isDuplicate(webhookId, ttlMs = 300000) {
  if (processedIds.has(webhookId)) {
    return true;
  }
  processedIds.set(webhookId, Date.now());

  // Clean up expired entries
  for (const [id, ts] of processedIds) {
    if (Date.now() - ts > ttlMs) processedIds.delete(id);
  }
  return false;
}

3. Enforce HTTPS and Reject Plaintext HTTP

If your webhook endpoint accepts HTTP (not HTTPS), every proxy, router, and network device between the sender and your server can read and modify the payload. HMAC signatures become useless if an attacker can intercept the request, modify the body, and recalculate the signature using a stolen secret.

HTTPS isn't optional for webhook endpoints. Configure your server to redirect HTTP to HTTPS or, better yet, refuse HTTP connections entirely on webhook routes. If you're behind a load balancer or reverse proxy, verify that TLS terminates at the proxy and the internal hop uses a trusted network. Check the X-Forwarded-Proto header to confirm the original connection used HTTPS:

function requireHTTPS(req, res, next) {
  const proto = req.headers['x-forwarded-proto'] || req.protocol;
  if (proto !== 'https') {
    return res.status(403).json({
      error: 'Webhook endpoints require HTTPS'
    });
  }
  next();
}

4. Restrict Access by IP Address

IP whitelisting adds a network-layer filter on top of your application-layer signature validation. Many webhook providers publish the IP ranges their servers use to send webhooks. By rejecting requests from any other IP, you block forged requests before they even reach your signature validation code.

Stripe publishes their webhook IPs in their documentation. GitHub provides theirs via the /meta API endpoint. Here's a basic IP filter middleware:

const ALLOWED_IPS = [
  '3.18.12.63',
  '3.130.192.231',
  '13.235.14.241',
  '13.235.122.149',
  // ... full list from provider docs
];

function ipWhitelist(allowedIPs) {
  return (req, res, next) => {
    const clientIP = req.headers['x-forwarded-for']
      ?.split(',')[0]?.trim()
      || req.socket.remoteAddress;

    if (!allowedIPs.includes(clientIP)) {
      console.warn('Webhook rejected: unauthorized IP ' + clientIP);
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

A word of caution: IP whitelisting should be a supplementary measure, not your primary security layer. IP addresses can change when providers update their infrastructure. Always validate HMAC signatures as the authoritative check and treat IP filtering as defense in depth.

5. Process Webhooks Asynchronously

This is a reliability concern with security implications. Webhook handlers that do heavy processing inline — database writes, API calls to third-party services, email sends — are vulnerable to timeout-based denial of service. If an attacker sends a flood of webhooks (even unsigned ones that fail validation), the processing overhead can exhaust your server's resources.

The correct architecture separates receipt from processing:

app.post('/webhooks/stripe', requireHTTPS, ipWhitelist(stripeIPs),
  async (req, res) => {
    try {
      // Step 1: Validate signature (fast)
      const event = verifyStripeSignature(
        req.rawBody,
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      );

      // Step 2: Check timestamp (fast)
      verifyTimestamp(event.timestamp);

      // Step 3: Check for duplicates (fast)
      if (isDuplicate(req.headers['stripe-webhook-id'])) {
        return res.status(200).json({ received: true });
      }

      // Step 4: Enqueue for async processing (fast)
      await queue.add('process-stripe-event', {
        eventId: event.id,
        payload: req.rawBody
      });

      // Step 5: Return 200 immediately
      res.status(200).json({ received: true });

    } catch (err) {
      console.error('Webhook validation failed:', err.message);
      res.status(400).json({ error: 'Validation failed' });
    }
  }
);

Return a 200 response immediately after validation and enqueuing. Most webhook providers interpret slow responses or timeouts as failures and will retry, creating duplicate processing issues. A message queue (BullMQ, SQS, RabbitMQ) handles the actual business logic in a worker process that can be scaled independently.

6. Keep Your Webhook Secrets Rotated

Webhook signing secrets are API keys by another name. They should follow the same API key management practices you apply to the rest of your credentials: stored in a secret manager, never hardcoded, and rotated on a regular schedule.

Most providers support rolling secret rotation — you can have two active secrets simultaneously, validate incoming webhooks against both, deploy the new secret, then revoke the old one. Stripe's webhook endpoints in the dashboard let you roll secrets with zero downtime.

Rotate webhook secrets immediately if a team member with access to the secret leaves the organization, if you suspect the secret has been exposed in logs or error reports, or if the secret has been active for more than 6 months without rotation.

7. Log Everything, Expose Nothing

Webhook requests often contain sensitive data — payment details, user information, authentication tokens. Your logging strategy needs to capture enough information for debugging without leaking sensitive payloads.

Log these: the webhook event type, a unique event ID, the timestamp, the validation result (pass/fail), and the HTTP status code your server returned. Don't log the raw request body, HMAC signatures, or webhook secrets. If you need to debug a specific event, use the provider's dashboard to look up the payload by event ID.

The Complete Webhook Security Checklist

Before your webhook integration goes to production, verify every item:

  1. HMAC signature validation is implemented with constant-time comparison.
  2. Timestamp verification rejects requests older than 5 minutes.
  3. Duplicate detection prevents replay attacks within the tolerance window.
  4. HTTPS is enforced — no plaintext HTTP accepted.
  5. IP whitelisting restricts access to the provider's published IP ranges.
  6. Processing is asynchronous — validate fast, enqueue, return 200.
  7. Secrets are managed properly — stored in environment variables or a secret manager, rotated regularly.
  8. Logging captures metadata only — event types and IDs, not raw payloads.
  9. Raw request body is preserved for signature verification (not parsed JSON).
  10. Error responses don't leak internals — return generic 400/403 messages.

Need to test your webhook integrations before going live? PinusX's Webhook Testing tool gives you a unique endpoint to send test payloads, inspect headers, and verify your integration works correctly — all from your browser. And when you're debugging the JSON payloads those webhooks deliver, the PinusX JSON Formatter processes everything client-side so your webhook data never leaves your machine.

Monitor Your APIs & Services

Get instant alerts when your endpoints go down. 60-second checks, free forever.

Start Monitoring Free →