🔥 0
0
Lesson 2 of 5 20 min +200 XP

Webhook Security

Your webhook endpoint is a public URL. Anyone who finds it can send requests to it. Without proper security, attackers could:

  • Trigger fake payment confirmations
  • Inject malicious data into your system
  • Cause your app to take unauthorized actions
Real Attack Scenario

An attacker discovers your /webhooks/stripe endpoint. They send a fake payment_intent.succeeded event. Without verification, your app fulfills an order that was never paid for.

Signature Verification

The solution: cryptographic signatures. The webhook sender creates a signature using a shared secret that only you and they know.

How It Works

┌─────────────────────────────────────────────────────────────────┐
│  SENDER (e.g., Stripe)                                          │
│                                                                 │
│  1. payload = '{"type": "payment_intent.succeeded", ...}'       │
│  2. signature = HMAC-SHA256(payload, shared_secret)             │
│  3. Send: payload + signature header                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  RECEIVER (Your App)                                            │
│                                                                 │
│  1. Receive: payload + signature header                         │
│  2. expected = HMAC-SHA256(payload, shared_secret)              │
│  3. Compare: signature === expected ?                           │
│     ✓ Match: Process webhook                                    │
│     ✗ No match: Reject (401/403)                                │
└─────────────────────────────────────────────────────────────────┘

HMAC Explained

HMAC (Hash-based Message Authentication Code) combines a hash function with a secret key:
HMAC-SHA256("Hello World", "my-secret-key")
// → "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"

What HMAC Proves

  • Authenticity: Sender knows the secret
  • Integrity: Payload wasn't modified

What HMAC Doesn't Do

  • Encrypt the payload (still plaintext)
  • Prevent replay attacks (need timestamp)

Stripe Signature Verification

Stripe's signature header format:

Stripe-Signature: t=1699999999,v1=abc123def456...
  • t = Unix timestamp when sent
  • v1 = HMAC-SHA256 signature

Implementation

const crypto = require('crypto');
const express = require('express');
const app = express();

const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;

// Must use raw body for signature verification
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['stripe-signature'];
    const payload = req.body;

    try {
      // Parse signature header
      const elements = signature.split(',').reduce((acc, part) => {
        const [key, value] = part.split('=');
        acc[key] = value;
        return acc;
      }, {});

      const timestamp = elements['t'];
      const receivedSignature = elements['v1'];

      // Check timestamp (prevent replay attacks)
      const tolerance = 300; // 5 minutes
      const currentTime = Math.floor(Date.now() / 1000);
      if (currentTime - parseInt(timestamp) > tolerance) {
        throw new Error('Timestamp too old');
      }

      // Compute expected signature
      const signedPayload = `${timestamp}.${payload}`;
      const expectedSignature = crypto
        .createHmac('sha256', STRIPE_WEBHOOK_SECRET)
        .update(signedPayload)
        .digest('hex');

      // Secure comparison (timing-attack safe)
      if (!crypto.timingSafeEqual(
        Buffer.from(expectedSignature),
        Buffer.from(receivedSignature)
      )) {
        throw new Error('Invalid signature');
      }

      // Signature valid - process event
      const event = JSON.parse(payload);
      console.log('Verified event:', event.type);

      res.status(200).json({ received: true });

    } catch (err) {
      console.error('Webhook verification failed:', err.message);
      res.status(401).json({ error: 'Invalid signature' });
    }
  }
);

Using Stripe's SDK (Recommended)

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

app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['stripe-signature'];

    try {
      // SDK handles all verification
      const event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET
      );

      // Event is verified - safe to process
      switch (event.type) {
        case 'payment_intent.succeeded':
          handlePaymentSuccess(event.data.object);
          break;
        // ... other events
      }

      res.status(200).json({ received: true });

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

GitHub Signature Verification

GitHub uses a similar HMAC approach with X-Hub-Signature-256 header:

const crypto = require('crypto');

function verifyGitHubWebhook(payload, signature, secret) {
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

app.post('/webhooks/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-hub-signature-256'];
    const payload = req.body;

    if (!verifyGitHubWebhook(payload, signature, GITHUB_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(payload);
    const eventType = req.headers['x-github-event'];

    console.log(`GitHub event: ${eventType}`);
    // Process verified event...

    res.status(200).json({ received: true });
  }
);

Timing-Safe Comparison

Why crypto.timingSafeEqual?

Normal string comparison (===) stops at the first different character, leaking timing information. Attackers can use this to guess the signature one character at a time. timingSafeEqual always takes the same time regardless of where strings differ.

// BAD: Vulnerable to timing attacks
if (signature === expectedSignature) { ... }

// GOOD: Constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { ... }

Replay Attack Prevention

Even with valid signatures, an attacker could capture a webhook and resend it. Use timestamps:

function verifyTimestamp(timestamp, toleranceSeconds = 300) {
  const webhookTime = parseInt(timestamp);
  const currentTime = Math.floor(Date.now() / 1000);
  const difference = Math.abs(currentTime - webhookTime);

  if (difference > toleranceSeconds) {
    throw new Error(`Webhook too old: ${difference} seconds`);
  }

  return true;
}

Timestamp in signature

Stripe includes timestamp in the signed payload. Changing it invalidates the signature.

Tolerance window

Allow 5-minute tolerance for clock drift between servers.

Additional Security Measures

1. IP Allowlisting

Some providers publish their webhook IP ranges:

const STRIPE_IPS = [
  '3.18.12.63',
  '3.130.192.231',
  // ... more IPs from Stripe's documentation
];

function verifyIP(req) {
  const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
  return STRIPE_IPS.includes(clientIP);
}
Note on IP Allowlisting

IP allowlisting is defense-in-depth but shouldn't be your only protection. IPs can change, and you might be behind proxies. Always use signature verification as the primary security measure.

2. Use HTTPS Only

// Reject non-HTTPS in production
app.use('/webhooks', (req, res, next) => {
  if (process.env.NODE_ENV === 'production' && !req.secure) {
    return res.status(403).json({ error: 'HTTPS required' });
  }
  next();
});

3. Unique, Secret URLs

Add a random path component:

Instead of: https://yourapp.com/webhooks/stripe
Use:        https://yourapp.com/webhooks/stripe/a8f9c3b2e1d4
const WEBHOOK_PATH_SECRET = process.env.WEBHOOK_PATH_SECRET;

app.post(`/webhooks/stripe/${WEBHOOK_PATH_SECRET}`, (req, res) => {
  // Extra layer - attackers must guess the URL
});

Security Checklist

Before Going Live

  • Signature verification implemented for all webhooks
  • Using timing-safe comparison for signatures
  • Timestamp validation to prevent replay attacks
  • HTTPS enforced in production
  • Webhook secrets stored securely (environment variables)
  • Raw body parsing (not JSON parsed) for signature verification
  • Failed verifications logged for monitoring

Common Mistakes

Parsing JSON Before Verification

JSON.parse can change the payload (whitespace, key order). Always verify the raw body string.

Skipping Verification in Development

Easy to forget to enable in production. Always verify, even locally with test secrets.

Logging Webhook Secrets

Never log secrets or full payloads with sensitive data. Redact before logging.

Single Secret for All Environments

Use different secrets for development, staging, and production.

Key Takeaways

  • Always verify signatures - Never trust unverified webhooks
  • Use timing-safe comparison - Prevent timing attacks
  • Validate timestamps - Prevent replay attacks
  • Verify raw body, not parsed JSON - Parsing can change content

Next up: Reliability Patterns - Handling retries, failures, and ensuring you never miss an event.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Why must you verify webhook signatures?

2

What is HMAC used for in webhook security?

3

What should you do if signature verification fails?

Introduction to Webhooks