Skip to content
StackPractices
intermediate By Mathias Paulenko

Webhook Security — Delivery, Verification, and Protection

A practical guide to securing webhooks: signature verification, replay attack prevention, payload encryption, and endpoint hardening for reliable delivery.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Webhooks are the duct tape of the internet: every SaaS product sends them, every integration consumes them, and almost nobody secures them properly. An unprotected webhook endpoint is an open door — anyone who discovers the URL can POST data to your system and trigger actions. Here is a hands-on guide to the essential security controls for webhook producers and consumers: signature verification, replay attack prevention, payload encryption, endpoint hardening, and delivery reliability.

When to Use

Use this guide when:

  • You are building a SaaS product that sends webhooks to customer endpoints
  • You are consuming webhooks from third-party services (Stripe, GitHub, Slack, etc.)
  • You have experienced webhook spoofing, replay attacks, or payload tampering

Solution

Signature Verification (Producer Side)

import hmac
import hashlib
import json

WEBHOOK_SECRET = "whsec_..."  # store in vault, not code

def sign_payload(payload: dict) -> str:
    """Sign a webhook payload with HMAC-SHA256."""
    payload_bytes = json.dumps(payload, separators=(',', ':')).encode()
    signature = hmac.new(
        WEBHOOK_SECRET.encode(),
        payload_bytes,
        hashlib.sha256
    ).hexdigest()
    return f"v1={signature}"

# Send webhook
headers = {
    "X-Webhook-Signature": sign_payload(payload),
    "X-Webhook-ID": str(uuid.uuid4()),
    "X-Webhook-Timestamp": str(int(time.time()))
}
requests.post(customer_url, json=payload, headers=headers)

Signature Verification (Consumer Side)

import hmac
import hashlib
import time

WEBHOOK_SECRET = "whsec_..."
MAX_AGE_SECONDS = 300  # 5 minutes

def verify_webhook(headers: dict, body: bytes) -> bool:
    """Verify webhook signature and prevent replay attacks."""
    # 1. Extract signature
    sig_header = headers.get("X-Webhook-Signature", "")
    if not sig_header.startswith("v1="):
        return False
    expected_sig = sig_header.split("=")[1]

    # 2. Compute signature
    computed = hmac.new(
        WEBHOOK_SECRET.encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    # 3. Constant-time comparison
    if not hmac.compare_digest(computed, expected_sig):
        return False

    # 4. Check timestamp (replay protection)
    timestamp = int(headers.get("X-Webhook-Timestamp", 0))
    if abs(time.time() - timestamp) > MAX_AGE_SECONDS:
        return False

    # 5. Check unique ID (idempotency / replay)
    webhook_id = headers.get("X-Webhook-ID")
    if is_duplicate_id(webhook_id):
        return False

    return True

Endpoint Hardening Checklist

# Flask example with security hardening
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhooks/stripe', methods=['POST'])
def handle_stripe_webhook():
    # 1. Reject non-POST methods at the edge
    if request.method != 'POST':
        abort(405)

    # 2. Validate Content-Type
    if request.content_type != 'application/json':
        abort(415)

    # 3. Size limit
    if request.content_length > 1_000_000:  # 1MB
        abort(413)

    # 4. IP allowlist (if provider publishes IPs)
    if request.remote_addr not in STRIPE_IPS:
        abort(403)

    # 5. Signature verification
    payload = request.get_data()
    sig = request.headers.get('Stripe-Signature')
    if not verify_stripe_signature(payload, sig):
        abort(400)

    # 6. Process asynchronously
    process_webhook_async.delay(payload)
    return '', 200

Payload Encryption (Sensitive Data)

from cryptography.fernet import Fernet

# Pre-shared key exchanged out-of-band
ENCRYPTION_KEY = Fernet.generate_key()
fernet = Fernet(ENCRYPTION_KEY)

# Encrypt before sending (producer)
encrypted_payload = fernet.encrypt(json.dumps(payload).encode())

# Decrypt after verification (consumer)
if verify_signature(headers, encrypted_payload):
    decrypted = fernet.decrypt(encrypted_payload)
    payload = json.loads(decrypted)

Explanation

Webhook security rests on three pillars: authenticity (did this really come from the sender?), freshness (is this a replay of an old message?), and integrity (was the payload modified in transit?).

HMAC-SHA256 signature verification solves authenticity and integrity. The sender computes a hash using a shared secret; the receiver recomputes it and compares. Crucially, use hmac.compare_digest() instead of == to prevent timing attacks. The secret must never be in URLs, query parameters, or client-side code — it belongs in a vault.

Replay protection requires two mechanisms: a timestamp check (reject messages older than 5 minutes) and an idempotency check (store webhook IDs for 24 hours and reject duplicates). Timestamps alone are insufficient because an attacker could replay within the window. ID storage alone is insufficient because your storage could fail. Use both.

Endpoint hardening is about reducing attack surface. Webhook endpoints should reject non-POST methods, validate content types, enforce size limits, and verify source IPs if the provider publishes them. Process webhooks asynchronously to prevent slowloris attacks from exhausting your workers.

Variants

ProviderSignature HeaderTimestamp HeaderKey FormatNotes
StripeStripe-SignatureIncluded in signature payloadwhsec_Tolerance configurable
GitHubX-Hub-Signature-256NoneWebhook secretNo timestamp; use delivery ID for replay
SlackX-Slack-SignatureX-Slack-Request-TimestampSigning secretVersioned signature scheme
CustomX-Webhook-SignatureX-Webhook-TimestampRaw HMAC keyFull control over scheme

What Works

  1. Rotate webhook secrets every 90 days or after any security incident
  2. Use HTTPS only; HTTP webhooks expose payloads to passive network sniffing
  3. Implement exponential backoff for delivery retries; don’t hammer failing endpoints
  4. Log every webhook (ID, timestamp, result) for at least 30 days for debugging
  5. Version your webhook schema; never break existing consumers with payload changes

Common Mistakes

  1. Verifying signatures with == instead of constant-time comparison; vulnerable to timing attacks
  2. Storing the webhook secret in environment variables without encryption at rest
  3. Processing webhooks synchronously; a burst of webhooks can DDoS your own service
  4. Not checking Content-Type; attackers can send crafted multipart requests to exploit parsers
  5. Ignoring failed deliveries; if your endpoint returns 500, the provider will retry — potentially amplifying damage

Troubleshooting

  • Authentication bypass in tests: ensure test users cannot reach production endpoints. Use separate credentials and environments for CI.
  • False positives in scanning tools: tune rules against the risk profile. Distinguish between reachable vulnerabilities and theoretical issues.
  • Secrets appear in logs: configure log filters to redact tokens, passwords, and keys. Audit log sinks for sensitive patterns.
  • CSP breaks legitimate functionality: use report-only mode first, then enforce. Iterate on allowed sources based on real violations.
  • Incident response stalls: run tabletop exercises. Document escalation paths, evidence collection steps, and communication templates in advance.

FAQ

What if my webhook provider doesn’t sign payloads?

If the provider doesn’t support signatures, you have three options: (1) IP allowlisting — restrict the endpoint to the provider’s published IP ranges, (2) Shared secret in a custom header — negotiate a secret and require it in a header, (3) TLS client certificates — use mutual TLS if the provider supports it. If none of these are available, treat the webhook as untrusted input and validate every field aggressively. Do not trigger irreversible actions (payments, deletions) based on unsigned webhooks.

How do I handle webhook retries and idempotency?

Store processed webhook IDs in a deduplication store (Redis with TTL, or a database with unique constraints). When a webhook arrives, check the ID before processing. Design your actions to be idempotent: charging a payment with the same IDempotency-Key should not create a duplicate charge. Return 200 for duplicates — don’t return 409, because the provider may interpret that as a failure and retry. Return 200 for valid webhooks even if you queue them for async processing.

Should I expose one webhook endpoint or multiple?

Multiple endpoints are better for security and reliability. A dedicated /webhooks/stripe endpoint can have Stripe-specific IP allowlisting, signature verification, and payload parsing. If you use one generic /webhooks endpoint, a bug in the parser for one provider could expose data from another provider. Multiple endpoints also make monitoring and alerting more granular. The only case for a single endpoint is if you have dozens of providers and need a generic webhook ingestion pipeline — but even then, route by path or header to provider-specific handlers.

Advanced Topics

Scenario: Stripe Webhook Signature Verification

// HMAC signature verification (Node.js)
const crypto = require("crypto");

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

  // Prevent replay attacks: reject > 5 min old
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    throw new Error("Webhook timestamp out of range");
  }

  // Calculate expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Secure comparison against timing attacks
  if (crypto.timingSafeEqual(
    Buffer.from(sig),
    Buffer.from(expectedSig)
  )) {
    return true;
  }
  throw new Error("Invalid signature");
}

// Webhook endpoint
app.post("/webhooks/stripe", (req, res) => {
  const sig = req.headers["stripe-signature"];
  const rawBody = req.rawBody; // Important: raw body, not parsed

  try {
    verifyStripeSignature(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }

  const event = JSON.parse(rawBody);
  // Idempotency: check if we already processed this event
  const processed = await db.query(
    "SELECT id FROM processed_webhooks WHERE id = $1",
    [event.id]
  );
  if (processed.rows.length > 0) {
    return res.status(200).send("Already processed");
  }

  // Process event
  switch (event.type) {
    case "payment_intent.succeeded":
      await handlePaymentSuccess(event.data.object);
      break;
    case "payment_intent.payment_failed":
      await handlePaymentFailure(event.data.object);
      break;
    default:
      console.log(`Unhandled event: ${event.type}`);
  }

  // Mark as processed
  await db.query(
    "INSERT INTO processed_webhooks (id, type, created_at) VALUES ($1, $2, NOW())",
    [event.id, event.type]
  );

  res.status(200).send("OK");
});

// Hardening:
//   1. Use raw body (no JSON.parse before verifying)
//   2. timingSafeEqual to prevent timing attacks
//   3. Timestamp check to prevent replay
//   4. Idempotency with DB unique constraint
//   5. TTL on processed_webhooks (7 days)
//   6. Rate limiting on the endpoint
//   7. IP allowlist for Stripe (3.18.12.63, 3.130.6.84, ...)

How do I handle webhooks from multiple providers?

Use separate endpoints per provider: /webhooks/stripe, /webhooks/github, /webhooks/slack. Each endpoint has its own verification logic, IP allowlist, and parsing. A bug in one provider parser does not affect others. Granular monitoring per endpoint. If you need a generic endpoint, route by path or header to provider-specific handlers.

Common Production Pitfalls

  • Treating the guide as a checklist to complete once rather than a practice to evolve.
  • Adopting every recommendation at once instead of starting with one measured change.
  • Skipping the maturity assessment and forcing advanced practices on an unprepared team.
  • Not updating runbooks and on-call expectations as new practices are introduced.
  • Ignoring real incident data when prioritizing which parts of the guide to apply first.
  • Failing to assign an owner who reviews decisions quarterly.
  • Copying examples without adapting them to the team’s actual tooling and constraints.
  • Forgetting to measure outcomes before adding the next improvement.