Back to Blog
Guide7 min read2026-05-01

Webhook Security: How to Validate and Secure Incoming Webhooks

Learn how to verify webhook signatures, prevent replay attacks, and securely process events from third-party services.

Webhook Security: How to Validate and Secure Incoming Webhooks

Webhooks are how the modern web communicates — GitHub notifies your CI pipeline, Stripe confirms a payment, and your monitoring tool fires an alert when a threshold is breached. But webhooks are also a common attack vector: an unverified webhook endpoint will happily process a forged request from anyone who discovers the URL.

This guide covers everything you need to secure your webhook endpoints.

The Core Problem

Your webhook endpoint is a public URL. Without verification, any attacker who knows (or guesses) the URL can send fake events — triggering deployments, updating records, or causing other unintended actions.

The solution is signature verification: the sender signs each request with a shared secret, and you verify the signature before processing the payload.

How Webhook Signatures Work

  1. 1You generate a shared secret when registering the webhook with the provider
  2. 2The provider computes an HMAC of the request body using that secret
  3. 3The provider sends the HMAC in a request header (e.g., X-Hub-Signature-256)
  4. 4You recompute the HMAC on your end and compare — if they match, the payload is authentic

Step 1: Verify GitHub Webhook Signatures

const crypto = require('crypto');

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

  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(req.rawBody); // must be the raw, unparsed body
  const digest = 'sha256=' + hmac.digest('hex');

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

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  req.rawBody = req.body;

  if (!verifyGitHubSignature(req, process.env.GITHUB_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body.toString());
  // process payload safely
  res.status(200).json({ received: true });
});

Critical: Use express.raw() (not express.json()) for webhook routes to preserve the raw body for signature verification. Parsing JSON first modifies the body and breaks HMAC comparison.

Step 2: Always Use timingSafeEqual

Never compare signatures with === or ==. String comparison in most languages short-circuits on the first mismatch, which leaks information about how close an attacker's guess is (a timing attack).

// Wrong — vulnerable to timing attacks
if (signature === digest) { ... }

// Right — constant-time comparison
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))

Step 3: Prevent Replay Attacks

A valid but old webhook payload, replayed by an attacker, can cause duplicate actions. Prevent this with timestamps:

function isReplayAttack(req) {
  const timestamp = req.headers['x-webhook-timestamp'];
  if (!timestamp) return true;

  const eventTime = parseInt(timestamp, 10) * 1000;
  const now = Date.now();
  const fiveMinutes = 5 * 60 * 1000;

  return Math.abs(now - eventTime) > fiveMinutes;
}

app.post('/webhooks/stripe', (req, res) => {
  if (isReplayAttack(req)) {
    return res.status(400).json({ error: 'Stale request rejected' });
  }
  // continue processing
});

Step 4: Use Idempotency Keys

Even with replay protection, network retries can cause duplicate deliveries. Use the event ID to deduplicate:

async function processWebhook(eventId, payload) {
  const alreadyProcessed = await WebhookEvent.findOne({ where: { eventId } });
  if (alreadyProcessed) return { status: 'already_processed' };

  await WebhookEvent.create({ eventId, processedAt: new Date() });
  // process payload
}

Step 5: Respond Quickly, Process Asynchronously

Webhook providers expect a fast response (usually within 5-30 seconds). For any processing that might take longer, acknowledge immediately and process in the background:

app.post('/webhooks/github', verifySignatureMiddleware, async (req, res) => {
  // Acknowledge immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  await queue.publish('webhook-events', req.body);
});

PandaStack's GitHub integration uses this pattern — deployment webhooks are acknowledged instantly and queued for processing.

Step 6: Use HTTPS and Restrict IPs

Your webhook endpoint must use HTTPS. Additionally, many providers publish their IP ranges — allowlisting those ranges adds a network-level layer of defense.

# Example: GitHub's IP ranges
curl https://api.github.com/meta | jq '.hooks'

Step 7: Log All Webhook Events

Log every received event with its ID, source, verification result, and processing outcome. This is invaluable for debugging and incident investigation.

logger.info({
  eventId: payload.id,
  source: 'github',
  event: req.headers['x-github-event'],
  verified: true,
  timestamp: new Date().toISOString(),
});

Webhooks and PandaStack

PandaStack's GitHub integration uses webhook signatures to securely receive push and PR events, triggering builds and deployments. Set up GitHub integration and webhook-based deployments via:

npm install -g @pandastack/cli
panda integration:connect github

Configure everything at [dashboard.pandastack.io](https://dashboard.pandastack.io). Full documentation at [docs.pandastack.io](https://docs.pandastack.io).

Summary

Secure webhooks require: signature verification using HMAC, constant-time comparison to prevent timing attacks, timestamp validation to prevent replay attacks, idempotency keys to handle duplicate delivery, fast acknowledgment with async processing, HTTPS, and comprehensive logging.

Ready to deploy?

Start free on PandaStack — no credit card required.

Start free on PandaStack

More in Guide

Browse all Guide articles →

See also