Security January 27, 2026

Webhook Security: Signature Verification and Best Practices

Learn how to secure your webhook endpoints with HMAC signature verification, and other security best practices. Prevent spoofed requests and attacks.

9 min read

Your webhook endpoint is a public URL. Anyone who knows it can send requests. Without proper security, an attacker could send fake payment confirmations, trigger unauthorized actions, or mess with your data.

This guide covers how to secure your webhook endpoints properly.

Why Webhook Security Matters

Consider what could happen if someone sends a fake webhook to your endpoint:

  • Fake payment confirmation — Grant access without actual payment
  • Fake order webhook — Trigger fulfillment for non-existent orders
  • Fake user events — Create or modify user accounts
  • Denial of service — Flood your endpoint with requests
Real attack vector

Attackers do scan for webhook endpoints. If you accept unsigned requests, you're vulnerable. This isn't theoretical — it happens.

Signature Verification

The primary defense is signature verification. Most webhook providers sign their payloads with HMAC (Hash-based Message Authentication Code). You verify the signature to confirm the request came from the real provider.

How HMAC Signing Works

  1. You share a secret key with the webhook provider
  2. When sending a webhook, the provider hashes the payload with your secret
  3. They include this hash in a header (e.g., X-Signature)
  4. You compute the same hash and compare
  5. If they match, the request is authentic

Stripe Signature Verification

javascript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  try {
    const event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
    
    // Signature valid, process the event
    handleEvent(event);
    res.json({ received: true });
    
  } catch (err) {
    // Signature invalid
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
});

Shopify Signature Verification

javascript
const crypto = require('crypto');

function verifyShopifyWebhook(req) {
  const hmac = req.headers['x-shopify-hmac-sha256'];
  const body = req.rawBody; // Need raw body, not parsed JSON
  
  const hash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
    .update(body, 'utf8')
    .digest('base64');
  
  return hmac === hash;
}

app.post('/webhook', (req, res) => {
  if (!verifyShopifyWebhook(req)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process the webhook
  handleWebhook(req.body);
  res.status(200).send('OK');
});

GitHub Signature Verification

javascript
const crypto = require('crypto');

function verifyGitHubWebhook(req) {
  const signature = req.headers['x-hub-signature-256'];
  const body = req.rawBody;
  
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
    .update(body)
    .digest('hex');
  
  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
Use timing-safe comparison

Always use crypto.timingSafeEqual() or equivalent. Regular string comparison (===) is vulnerable to timing attacks that can reveal your secret.

Handling Raw Body

A common mistake: you parse the JSON body before verifying the signature. But signature verification needs the raw body exactly as received.

javascript
// Express: Capture raw body for webhook routes
app.use('/webhook', express.raw({ type: 'application/json' }));

// For other routes, use normal JSON parsing
app.use(express.json());

Timestamp Validation

Some providers (like Stripe) include a timestamp in the signature. This prevents replay attacks where someone captures a valid webhook and sends it again later.

javascript
// Stripe's constructEvent automatically checks timestamp
// Rejects webhooks older than 5 minutes by default

// For manual verification:
const tolerance = 300; // 5 minutes in seconds
const timestamp = parseInt(req.headers['stripe-signature-timestamp']);
const now = Math.floor(Date.now() / 1000);

if (now - timestamp > tolerance) {
  throw new Error('Webhook timestamp too old');
}

Additional Security Measures

1. Use HTTPS

Always use HTTPS for webhook endpoints. HTTP is unencrypted and vulnerable to man-in-the-middle attacks. Most providers require HTTPS anyway.

2. Respond Quickly

Return a response as fast as possible (ideally under 5 seconds). Long-running processing should happen asynchronously after responding.

javascript
app.post('/webhook', async (req, res) => {
  // Verify signature first
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid');
  }
  
  // Acknowledge receipt immediately
  res.status(200).send('OK');
  
  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});

3. Use Unique Endpoint URLs

Instead of /webhook, use a URL with a random component:

/webhook/a8f3b2c1-9d4e-5f6a-7b8c-9d0e1f2a3b4c

This adds obscurity — attackers can't guess the URL. It's not a replacement for signature verification, but it's an extra layer.

4. Rate Limiting

Implement rate limiting to prevent abuse. Even with signature verification, you don't want to process thousands of requests per second.

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

const webhookLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100, // 100 requests per minute
  message: 'Too many requests'
});

app.use('/webhook', webhookLimiter);

5. Log Everything

Log all webhook requests — successful and failed. This helps with debugging and detecting attacks.

javascript
app.post('/webhook', (req, res) => {
  const logData = {
    timestamp: new Date().toISOString(),
    ip: req.ip,
    headers: req.headers,
    verified: false
  };
  
  if (!verifySignature(req)) {
    logData.error = 'Invalid signature';
    logger.warn('Webhook rejected', logData);
    return res.status(401).send('Invalid');
  }
  
  logData.verified = true;
  logData.eventType = req.body.type;
  logger.info('Webhook received', logData);
  
  // Process...
});

Development vs Production

During local development, you might want to skip signature verification (especially when replaying webhooks). But never in production.

javascript
function verifyWebhook(req) {
  // ONLY skip in development
  if (process.env.NODE_ENV === 'development' && 
      process.env.SKIP_WEBHOOK_VERIFICATION === 'true') {
    console.warn('⚠️ Skipping webhook verification (dev mode)');
    return true;
  }
  
  return verifySignature(req);
}
Never skip in production

Ensure your environment variables are set correctly. Accidentally deploying with verification disabled is a serious vulnerability.

Testing Security

Test that your security measures work:

  • Send a request with no signature — should be rejected
  • Send a request with wrong signature — should be rejected
  • Send a request with old timestamp — should be rejected
  • Send a valid request — should be accepted

With HookReplay, you can edit webhook payloads to test these scenarios. Modify the signature header and verify your handler rejects it.

Summary

Webhook security isn't optional. Without it, your endpoint is an open door for attackers.

Key takeaways:

  • Always verify signatures using the provider's secret
  • Use timing-safe comparison functions
  • Preserve the raw body for signature verification
  • Check timestamps to prevent replay attacks
  • Add rate limiting and logging
  • Never disable verification in production

A few lines of verification code protect your entire application.

Test your webhook security

Use HookReplay to test that invalid signatures are properly rejected.

Get Started Free
HR

HookReplay Team

We're building the fastest way to debug webhooks on localhost. Follow us on Twitter for tips and updates.