DEV Community

WebhookScout
WebhookScout

Posted on

Webhook Security Best Practices: Protecting Your Endpoints in Production

Webhook Security Best Practices: Protecting Your Endpoints in Production

Webhooks are powerful — they let external services push real-time data to your application without polling. But every webhook endpoint you expose is an open door into your system. Without proper security, attackers can forge payloads, replay requests, or overwhelm your servers.

This guide covers seven battle-tested practices for securing webhook endpoints in production, with practical Node.js examples you can implement today.

1. Verify Webhook Signatures (HMAC-SHA256)

The single most important security measure. Most webhook providers (Stripe, GitHub, Shopify) sign payloads with a shared secret using HMAC-SHA256. Always verify these signatures before processing any payload.

const crypto = require('crypto');

function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-webhook-signature'];
  if (!signature) return false;

  const payload = JSON.stringify(req.body);
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expectedSig, 'hex')
  );
}

app.post('/webhooks/incoming', (req, res) => {
  if (!verifyWebhookSignature(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  handleWebhook(req.body);
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Critical detail: Use crypto.timingSafeEqual() instead of === for signature comparison. Simple string comparison leaks timing information that attackers can exploit to guess valid signatures byte by byte.

Tip: When developing locally, tools like WebhookScout let you inspect incoming webhook headers and payloads in real time — making it easy to see exactly what signature format a provider sends before you write verification logic.

2. Prevent Replay Attacks with Timestamps

Even with valid signatures, an attacker who intercepts a legitimate webhook can replay it later. Defend against this by checking the request timestamp:

function isRequestFresh(req, maxAgeSeconds = 300) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);
  if (!timestamp) return false;

  const now = Math.floor(Date.now() / 1000);
  return Math.abs(now - timestamp) <= maxAgeSeconds;
}

app.post('/webhooks/incoming', (req, res) => {
  if (!isRequestFresh(req)) {
    return res.status(408).json({ error: 'Request too old' });
  }
  if (!verifyWebhookSignature(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  handleWebhook(req.body);
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

For stronger protection, combine timestamps with a nonce (unique request ID). Store processed nonces in Redis with a TTL matching your timestamp window:

const redis = require('redis');
const client = redis.createClient();

async function isDuplicate(requestId, ttlSeconds = 300) {
  const key = `webhook:nonce:${requestId}`;
  const exists = await client.exists(key);
  if (exists) return true;
  await client.setEx(key, ttlSeconds, '1');
  return false;
}
Enter fullscreen mode Exit fullscreen mode

3. IP Allowlisting

If your webhook provider publishes their IP ranges, restrict incoming requests to only those addresses:

const ALLOWED_IPS = [
  '192.30.252.0/22',   // Example: GitHub webhook IPs
  '140.82.112.0/20',
];
const { isInSubnet } = require('is-in-subnet');

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

  const allowed = ALLOWED_IPS.some(range => isInSubnet(clientIP, range));
  if (!allowed) return res.status(403).json({ error: 'Forbidden' });
  next();
}

app.post('/webhooks/github', ipAllowlist, (req, res) => {
  handleWebhook(req.body);
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Caveat: IP ranges change. Automate fetching them — GitHub publishes theirs at https://api.github.com/meta.

4. Rate Limiting

Even authenticated webhooks can overwhelm your system. Apply rate limiting:

const rateLimit = require('express-rate-limit');

const webhookLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: { error: 'Too many requests' },
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/webhooks/incoming', webhookLimiter, (req, res) => {
  handleWebhook(req.body);
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

5. Validate and Sanitize Payloads

Never trust webhook payloads blindly. Validate structure and types:

const Ajv = require('ajv');
const ajv = new Ajv();

const webhookSchema = {
  type: 'object',
  properties: {
    event: { type: 'string', enum: ['order.created', 'order.updated', 'order.cancelled'] },
    data: {
      type: 'object',
      properties: {
        id: { type: 'string', maxLength: 64 },
        amount: { type: 'number', minimum: 0, maximum: 1000000 },
        currency: { type: 'string', pattern: '^[A-Z]{3}$' },
      },
      required: ['id', 'amount'],
    },
  },
  required: ['event', 'data'],
  additionalProperties: false,
};

const validate = ajv.compile(webhookSchema);

app.post('/webhooks/orders', (req, res) => {
  if (!validate(req.body)) {
    return res.status(400).json({ error: 'Invalid payload' });
  }
  handleWebhook(req.body);
  res.status(200).json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

6. Enforce HTTPS and Use Webhook Secrets Wisely

HTTPS is non-negotiable. Without TLS, signatures and secrets are visible in transit.

For your webhook secrets:

  • Never hardcode them. Use environment variables or a secrets manager.
  • Use unique secrets per webhook source.
  • Minimum 32 characters of cryptographically random data.
const secret = crypto.randomBytes(32).toString('hex');
Enter fullscreen mode Exit fullscreen mode

7. Rotate Secrets Without Downtime

Support dual secrets for zero-downtime rotation:

function verifyWithRotation(req, secrets) {
  const signature = req.headers['x-webhook-signature'];
  if (!signature) return false;

  const payload = JSON.stringify(req.body);
  return secrets.some(secret => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(payload, 'utf8')
      .digest('hex');
    try {
      return crypto.timingSafeEqual(
        Buffer.from(signature, 'hex'),
        Buffer.from(expected, 'hex')
      );
    } catch { return false; }
  });
}

const secrets = [
  process.env.WEBHOOK_SECRET_CURRENT,
  process.env.WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

app.post('/webhooks/provider',
  ipAllowlist,
  webhookLimiter,
  (req, res, next) => {
    if (!isRequestFresh(req)) return res.status(408).json({ error: 'Stale request' });
    next();
  },
  async (req, res, next) => {
    const reqId = req.headers['x-request-id'];
    if (reqId && await isDuplicate(reqId)) return res.status(409).json({ error: 'Duplicate' });
    next();
  },
  (req, res, next) => {
    if (!verifyWithRotation(req, secrets)) return res.status(401).json({ error: 'Invalid signature' });
    next();
  },
  (req, res) => {
    if (!validate(req.body)) return res.status(400).json({ error: 'Invalid payload' });
    handleWebhook(req.body);
    res.status(200).json({ received: true });
  }
);
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Practice Protects Against
HMAC signature verification Forged payloads
Timing-safe comparison Timing attacks
Timestamp validation Replay attacks
Nonce deduplication Duplicate processing
IP allowlisting Unauthorized sources
Rate limiting DDoS, flood attacks
Schema validation Injection, malformed data
HTTPS Eavesdropping, MITM
Secret rotation Compromised credentials

Final Thoughts

Webhook security is not a single feature — it is a stack. Start with signature verification, then layer on timestamp checks, rate limiting, and schema validation.

When debugging your security implementation, seeing exact headers and payloads arriving at your endpoint is invaluable. WebhookScout gives you real-time visibility into what is hitting your endpoints during development.

Build the habit of treating every webhook endpoint like a public API: authenticate, validate, rate-limit, and monitor.

Top comments (0)