Webhook Security: How to Verify and Secure Incoming Webhooks

Webhooks are the backbone of event-driven architectures, but they are also one of the most commonly misconfigured attack surfaces in web applications. An unsecured webhook endpoint is an open door for forged events, replay attacks, and data injection. This guide covers the essential security practices that every webhook consumer must implement, with real examples from Stripe, GitHub, Twilio, and Shopify.

Why Webhook Security Is Critical

When you register a webhook, you are telling a third-party service to send HTTP POST requests to your server whenever events occur. Without verification, anyone who discovers your webhook URL can send fabricated events. The consequences depend on what your webhook handler does:

The fundamental problem: Your webhook endpoint is a publicly accessible URL. Anyone can send a POST request to it. Verification is the only way to distinguish legitimate events from fabricated ones.

Signature Verification

Signature verification is the primary defense for webhook security. The webhook sender computes a cryptographic signature over the request body using a shared secret, and includes it in a header. Your server recomputes the signature and compares.

How Stripe Does It

Stripe uses HMAC-SHA256 with a versioned signature scheme:

# Stripe signature header format:
# Stripe-Signature: t=1679510400,v1=abc123...,v0=def456...

import hmac
import hashlib
import time

def verify_stripe_signature(payload, sig_header, secret):
    # Parse the signature header
    elements = dict(item.split('=', 1)
                    for item in sig_header.split(','))

    timestamp = elements['t']
    expected_sig = elements['v1']

    # Construct the signed payload
    signed_payload = f"{timestamp}.{payload}"

    # Compute the expected signature
    computed_sig = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(computed_sig, expected_sig):
        raise ValueError("Invalid signature")

    # Check timestamp tolerance (5 minutes)
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")

Key features of Stripe's approach:

How GitHub Does It

GitHub uses HMAC-SHA256 with a simpler header format:

# GitHub signature header:
# X-Hub-Signature-256: sha256=abc123...

import hmac
import hashlib

def verify_github_signature(payload, sig_header, secret):
    expected_sig = "sha256=" + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected_sig, sig_header):
        raise ValueError("Invalid signature")

How Shopify Does It

Shopify uses HMAC-SHA256 with a Base64-encoded signature:

# Shopify signature header:
# X-Shopify-Hmac-Sha256: base64encodedhmac...

import hmac
import hashlib
import base64

def verify_shopify_signature(payload, sig_header, secret):
    computed = base64.b64encode(
        hmac.new(
            secret.encode(),
            payload,
            hashlib.sha256
        ).digest()
    ).decode()

    if not hmac.compare_digest(computed, sig_header):
        raise ValueError("Invalid signature")

Critical Implementation Details

Replay Attack Protection

Even with valid signatures, an attacker who intercepts a legitimate webhook payload can replay it later. If your handler is not idempotent, this can cause duplicate processing (double charges, duplicate orders, etc.).

Timestamp Verification

The first defense against replay attacks is checking the event's timestamp:

const TOLERANCE_SECONDS = 300; // 5 minutes

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

  if (diff > TOLERANCE_SECONDS) {
    throw new Error(`Webhook timestamp too old: ${diff}s`);
  }
}

Stripe includes the timestamp in both the header and the signed payload, so it cannot be tampered with. GitHub does not include timestamps, so you must rely on other replay protection mechanisms.

Event ID Deduplication

The most robust replay protection stores processed event IDs and rejects duplicates:

// Node.js with Redis for deduplication
const Redis = require('ioredis');
const redis = new Redis();

async function processWebhook(event) {
  const eventId = event.id;
  const key = `webhook:processed:${eventId}`;

  // Try to set the key (NX = only if not exists)
  // Expire after 72 hours to prevent unbounded growth
  const isNew = await redis.set(key, '1', 'NX', 'EX', 259200);

  if (!isNew) {
    console.log(`Duplicate webhook ${eventId}, skipping`);
    return { status: 200, message: 'Already processed' };
  }

  // Process the event
  await handleEvent(event);
  return { status: 200, message: 'Processed' };
}
Always return 200 for duplicates: Even when rejecting a duplicate, return a 200 OK response. If you return an error, the sender will retry the event, creating an infinite loop of duplicate deliveries.

Idempotent Handler Design

Beyond deduplication, design your webhook handlers to be idempotent from the start:

IP Allowlisting

As a defense-in-depth measure, restrict your webhook endpoint to only accept requests from the sender's known IP ranges. This prevents attackers from even reaching your verification logic.

Known IP Ranges

ProviderIP Range SourceUpdate Frequency
Stripestripe.com/docs/ipsPublished list, infrequent changes
GitHubGET https://api.github.com/metaDynamic, check hooks array
TwilioPublished listPublished list
ShopifyNot publishedN/A (use signature verification)
# Nginx IP allowlist example for Stripe webhooks
location /webhooks/stripe {
    # Stripe webhook IPs (check docs for current list)
    allow 3.18.12.63;
    allow 3.130.192.0/24;
    allow 13.235.14.0/24;
    allow 35.154.171.0/24;
    deny all;

    proxy_pass http://app:3000;
}

Caveats

HTTPS and TLS

Your webhook endpoint must use HTTPS. This is not optional. Without TLS:

Use a valid TLS certificate from a trusted CA. Self-signed certificates will be rejected by most webhook senders.

Request Body Size Limits

Webhook endpoints should enforce request body size limits to prevent denial-of-service attacks through oversized payloads:

// Express.js - limit webhook body to 1MB
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json', limit: '1mb' }),
  (req, res) => {
    // req.body is a Buffer (raw bytes for signature verification)
    const sig = req.headers['stripe-signature'];
    const event = stripe.webhooks.constructEvent(req.body, sig, secret);
    // ... handle event
  }
);

Error Handling and Retry Behavior

Understand how each provider handles failed deliveries:

ProviderRetry StrategyMax RetriesTimeout
StripeExponential backoff over 3 days~15 retries30 seconds
GitHubExponential backoff~25 retries10 seconds
TwilioFixed intervalConfigurable15 seconds
ShopifyExponential backoff over 48 hours19 retries5 seconds

Best Practices for Responses

// Recommended pattern: verify, queue, acknowledge
app.post('/webhooks/stripe', async (req, res) => {
  try {
    // 1. Verify signature
    const event = stripe.webhooks.constructEvent(
      req.body, req.headers['stripe-signature'], secret
    );

    // 2. Queue for processing
    await queue.send({
      type: event.type,
      data: event.data,
      id: event.id,
      received_at: Date.now()
    });

    // 3. Acknowledge immediately
    res.status(200).json({ received: true });
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

Security Checklist

Use this checklist for every webhook integration:

  1. HTTPS only — Never accept webhooks over plain HTTP.
  2. Verify signatures — Implement HMAC verification using the provider's documented method.
  3. Use constant-time comparison — Never use == for signature comparison.
  4. Verify against raw body — Do not parse JSON before verifying the signature.
  5. Check timestamps — Reject events older than 5 minutes (if the provider includes timestamps).
  6. Deduplicate events — Track processed event IDs and reject duplicates.
  7. Design idempotent handlers — Assume every event may be delivered more than once.
  8. Enforce body size limits — Cap request body size at 1-5MB.
  9. IP allowlist (if possible) — Restrict to the provider's published IP ranges.
  10. Return 200 quickly — Acknowledge and process asynchronously.
  11. Store secrets securely — Signing secrets in env vars or secrets manager, never in code.
  12. Rotate secrets periodically — Regenerate webhook signing secrets at least annually.
The minimum viable security: At bare minimum, every webhook endpoint must implement signature verification and HTTPS. Everything else is defense-in-depth. But without those two, your webhook endpoint is an unauthenticated public API that accepts arbitrary commands.

Webhook security is not glamorous, but it is foundational. A well-secured webhook handler is invisible — it just works. A poorly secured one is a ticking vulnerability that will eventually be exploited. Take the time to implement it correctly from the start.

Further Reading

Recommended books: