Back to blog
6 min read

Webhook Security Best Practices

How to secure your webhook endpoints with signature verification, HMAC, replay attack prevention, and IP allowlisting.

WG

WebhookGuide

March 19, 2026

Your webhook endpoint is a publicly accessible URL that accepts POST requests from the internet. If you do not secure it properly, anyone who discovers the URL can send fake payloads to your application. A forged "payment succeeded" webhook could grant unauthorized access. A forged "user deleted" webhook could wipe real data. Webhook security is not optional.

This guide covers the techniques you need to lock down your webhook endpoints.

Signature Verification With HMAC

The most important security measure for any webhook endpoint is signature verification. The provider includes a cryptographic signature in the request headers, computed from the payload and a shared secret. You recompute the signature on your end and compare. If they match, the payload is authentic.

Most providers use HMAC-SHA256. Here is how it works:

  1. When you register your webhook, the provider gives you a signing secret (sometimes called a webhook secret or endpoint secret).
  2. When the provider sends a webhook, it computes HMAC-SHA256(secret, raw_request_body) and includes the result in a header.
  3. Your endpoint reads the raw request body, computes the same HMAC using your copy of the secret, and compares the two values.

Here is a concrete example using Node.js to verify a Stripe webhook signature:

const crypto = require("crypto");

function verifyStripeSignature(payload, signatureHeader, secret) {
  const elements = signatureHeader.split(",");
  const timestamp = elements
    .find((e) => e.startsWith("t="))
    ?.split("=")[1];
  const signature = elements
    .find((e) => e.startsWith("v1="))
    ?.split("=")[1];

  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

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

And the equivalent in Python:

import hmac
import hashlib

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode("utf-8"),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Critical Details

Use the raw request body. Parse the signature from the header, but compute the HMAC against the raw bytes of the request body, not a parsed-and-reserialized JSON object. JSON serialization is not deterministic — key order, whitespace, and unicode escaping can differ between implementations. If you parse the JSON and then stringify it, the HMAC will not match.

Use constant-time comparison. Never compare signatures with == or ===. Use crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. Standard string comparison leaks timing information that can be exploited to forge signatures byte by byte.

Store the secret securely. The webhook signing secret should be stored in an environment variable or a secrets manager, never in source code. If the secret is compromised, an attacker can forge valid signatures. Rotate it immediately if you suspect a leak.

Replay Attack Prevention

Signature verification proves the payload was created by the provider, but it does not prove the payload is fresh. An attacker who intercepts a legitimate webhook can replay it — sending the same valid, signed payload to your endpoint again. If your endpoint processes it a second time, that is a replay attack.

Timestamp Validation

Most webhook providers include a timestamp in the signature or the payload. Check that the timestamp is recent — within five minutes is a common threshold:

function isTimestampValid(timestamp, toleranceSeconds = 300) {
  const eventTime = parseInt(timestamp, 10) * 1000;
  const now = Date.now();
  return Math.abs(now - eventTime) < toleranceSeconds * 1000;
}

If the timestamp is too old, reject the request. This limits the window during which a captured webhook can be replayed.

Event ID Deduplication

For stronger replay protection, track which event IDs you have already processed. Every major webhook provider includes a unique event ID in the payload (Stripe uses id, GitHub uses X-GitHub-Delivery, etc.). Store processed event IDs in a database or cache:

def handle_webhook(event):
    event_id = event["id"]

    # Check if already processed
    if redis.exists(f"webhook:processed:{event_id}"):
        return Response(status=200)  # Already handled, skip

    # Process the event
    process_event(event)

    # Mark as processed (expire after 72 hours)
    redis.setex(f"webhook:processed:{event_id}", 259200, "1")

    return Response(status=200)

Set the expiration to match or exceed the provider's retry window. If a provider retries for up to 72 hours, keep your deduplication records for at least 72 hours.

IP Allowlisting

Some webhook providers publish the IP addresses they send webhooks from. By restricting your endpoint to accept requests only from these IPs, you add a network-level security layer.

For example, you might configure your firewall or reverse proxy to only allow traffic from a known set of provider IPs:

location /webhooks/stripe {
    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 about IP allowlisting:

  • IP addresses change. Providers add and remove IPs over time. You need a process to keep the allowlist updated.
  • It does not replace signature verification. IP allowlisting is a defense-in-depth measure, not a substitute for cryptographic verification. IPs can be spoofed in some network configurations.
  • It does not work behind some CDNs or load balancers. Make sure you are checking the actual source IP, not a forwarded header that can be manipulated.

Use IP allowlisting as an additional layer, not your only security mechanism.

HTTPS Only

Your webhook endpoint must use HTTPS. Without TLS, the payload — including the signature header — travels in plaintext across the network. An attacker performing a man-in-the-middle attack could read the payload, capture the signature, and replay the request.

Most providers will refuse to send webhooks to HTTP URLs in production. Some allow it for development (e.g., localhost), but your production endpoint should always be HTTPS with a valid certificate.

Secret Rotation

Webhook secrets should be rotated periodically, and immediately if you suspect a compromise. The process for rotating a webhook secret depends on the provider, but the general pattern is:

  1. Generate a new secret in the provider's dashboard or via their API.
  2. Update your application to accept signatures from both the old and new secrets during a transition period.
  3. Once all in-flight webhooks signed with the old secret have been delivered (wait at least as long as the retry window), remove the old secret from your application.

Some providers (like Stripe) support multiple active signing secrets specifically to enable zero-downtime rotation.

function verifyWithRotation(payload, signature, secrets) {
  for (const secret of secrets) {
    if (verifySignature(payload, signature, secret)) {
      return true;
    }
  }
  return false;
}

Request Body Size Limits

Set a maximum request body size on your webhook endpoint. A legitimate webhook payload is typically under 100 KB. If someone sends a 500 MB POST request to your endpoint, you do not want your server to allocate memory for it.

Configure your web framework or reverse proxy to reject oversized requests:

// Express.js
app.post("/webhooks/stripe",
  express.raw({ type: "application/json", limit: "1mb" }),
  handleWebhook
);
# Nginx
location /webhooks/ {
    client_max_body_size 1m;
    proxy_pass http://app:3000;
}

Logging and Monitoring

Security is not just prevention — it is also detection. Log every webhook request your endpoint receives, including:

  • The raw payload (or a hash of it, if payloads contain sensitive data)
  • The signature header
  • Whether signature verification passed or failed
  • The source IP address
  • The response your endpoint returned

Set up alerts for anomalies:

  • A sudden spike in webhook volume may indicate an attack or a misconfiguration on the provider's side.
  • Repeated signature verification failures may indicate someone is sending forged payloads.
  • Requests from unexpected IP addresses (if you track provider IPs) may indicate spoofing attempts.

Security Checklist

Before you ship a webhook endpoint to production, verify that you have:

  • HMAC signature verification using the provider's signing secret and constant-time comparison
  • Timestamp validation rejecting payloads older than five minutes
  • Event ID deduplication to prevent replay attacks
  • HTTPS only with a valid TLS certificate
  • Request body size limits to prevent denial-of-service via oversized payloads
  • Secrets stored securely in environment variables or a secrets manager, not in code
  • Logging of all incoming requests, including verification results
  • Monitoring and alerts for anomalous traffic patterns

Webhook security is not glamorous, but it is essential. A single unverified endpoint is a vector for data manipulation, unauthorized access, and financial fraud. Take the time to implement every layer of this checklist. Your future self will thank you when the alert does not fire.