Guide

Webhook security best practices signatures, replay protection, rotation

The most common webhook incidents are not exotic — they are forged requests, broken signature verification, and replayed events. This guide gives you a threat model and the minimal verification patterns that hold up in production.

No credit card required

TL;DR

  • Assume every inbound request is forged until you verify it (signature + timestamp).
  • Verify signatures over the raw request body (bytes) and use constant-time comparison.
  • Validate timestamps with a max-age window (e.g. 5 minutes) and tolerate small clock skew.
  • Protect against replay: dedupe on provider event ID / delivery ID and reject repeats.
  • Rotate secrets safely: accept {current, previous} for an overlap window, then retire.
  • Enforce body limits, content-type allowlists, and rate limits on your webhook endpoint.
  • Log verification failures with enough context to debug (never log secrets or full PII payloads).
With Hooque, most of the above is handled for you — jump to “How Hooque helps” .

Security and retries are intertwined: if you accept replays, you also accept duplicates. See retries & backoff.

Anti-patterns

  • Relying on IP allowlists as the primary control (IPs change; signatures do not).
  • Parsing/transforming the body before verification (breaks HMAC signature inputs).
  • Treating “2xx” as success before authenticity is proven (you just acknowledged an attacker).

If you are debugging auth failures right now, jump to the debugging playbook.

Core concepts

Webhook security is about proving authenticity and making replays harmless — without breaking legitimate retries.

Threat model

Assume attackers can send requests to your endpoint. Your job is to reject forged traffic and avoid cross-tenant confusion.

Signature + timestamp

Signatures prove the sender knows a secret; timestamps + max-age reduce replay. Verify over raw bytes before parsing.

Replay + idempotency

Even authentic events can be replayed (intentionally or via retries). Dedupe on event IDs/delivery IDs and keep side effects idempotent.

Simple flow

Provider

Sends signed request

timestamp + signature

Receiver

Verify authenticity

reject forged + stale

Queue / Worker

Process idempotently

dedupe + side effects

If you are using Hooque, signature verification happens before messages enter the queue — your workers only see verified events.

Production checklist

Use this as a security review checklist for every webhook endpoint (including internal partner webhooks).

- [ ] HTTPS only; reject plain HTTP
- [ ] Body size limits (per provider) + hard max for unknown senders
- [ ] Content-Type allowlist (e.g. application/json) + safe fallback handling
- [ ] Signature verification uses the raw request body bytes
- [ ] Constant-time signature compare (mitigate timing leaks)
- [ ] Timestamp validation (max age + clock skew tolerance)
- [ ] Replay protection: dedupe on event ID / delivery ID for a window
- [ ] Secret storage in a KMS/secret manager (not env vars in source control)
- [ ] Secret rotation: accept {current, previous} during overlap, then revoke
- [ ] Multi-tenant safety: bind secret lookup to tenant/webhook identity
- [ ] Rate limits + abuse controls (burst + sustained)
- [ ] Structured logs for: verified/failed, reason, provider, webhook id, request id
- [ ] Alert on spikes in signature failures (often misconfig or attack)

Reference implementation

Minimal correct signature verification: raw body, constant-time compare, timestamp validation, fast 202.

Node

Express raw body + HMAC verification

// Express receiver with raw-body signature verification (HMAC + timestamp)
// npm i express
import crypto from "crypto";
import express from "express";

const app = express();

// Capture raw body bytes for signature verification.
app.post(
  "/webhook",
  express.raw({ type: "*/*", limit: "2mb" }),
  async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET ?? "whsec_replace_me";
    const timestamp = req.header("x-webhook-timestamp") ?? "";
    const signature = req.header("x-webhook-signature") ?? "";

    const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
    const maxAgeSeconds = 300;
    const nowSeconds = Math.floor(Date.now() / 1000);

    if (!timestamp || !signature) return res.status(401).send("missing signature headers");
    if (Math.abs(nowSeconds - Number(timestamp)) > maxAgeSeconds) return res.status(401).send("stale timestamp");

    // Example scheme: HMAC_SHA256(secret, timestamp + "." + rawBody)
    const signed = Buffer.concat([Buffer.from(timestamp + ".", "utf8"), rawBody]);
    const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");

    const expectedBuf = Buffer.from(expected, "utf8");
    const providedBuf = Buffer.from(signature, "utf8");
    if (expectedBuf.length !== providedBuf.length) return res.status(401).send("bad signature");
    if (!crypto.timingSafeEqual(expectedBuf, providedBuf)) return res.status(401).send("bad signature");

    // At this point the request is authentic. Ack fast.
    res.sendStatus(202);

    // TODO: enqueue rawBody + selected headers for async processing
    // IMPORTANT: store a provider event ID / delivery ID to dedupe replays.
  }
);

app.listen(3000, () => console.log("listening on :3000"));

Python

FastAPI raw body + HMAC verification

# FastAPI receiver with raw-body signature verification (HMAC + timestamp)
# pip install fastapi uvicorn
import hashlib
import hmac
import os
import time
from fastapi import FastAPI, Request, Response

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request) -> Response:
    secret = os.getenv("WEBHOOK_SECRET", "whsec_replace_me").encode("utf-8")
    timestamp = request.headers.get("x-webhook-timestamp", "")
    signature = request.headers.get("x-webhook-signature", "")
    raw_body = await request.body()

    if not timestamp or not signature:
        return Response("missing signature headers", status_code=401)

    max_age_seconds = 300
    now = int(time.time())
    if abs(now - int(timestamp)) > max_age_seconds:
        return Response("stale timestamp", status_code=401)

    signed = (timestamp + ".").encode("utf-8") + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()

    # Constant-time compare
    if not hmac.compare_digest(expected, signature):
        return Response("bad signature", status_code=401)

    # Authentic. Ack fast.
    # TODO: enqueue raw_body + selected headers for async processing (dedupe on event id/delivery id).
    return Response(status_code=202)
For delivery semantics and retries, start with Webhook API.

Common failure modes

Most security failures are configuration drift. Treat auth failures as production incidents until proven otherwise.

Sudden spike in signature failures

Likely causes

  • Secret rotated on the provider but not in your receiver.
  • Wrong environment secret (test/prod mismatch).
  • You changed body parsing and now verify different bytes.

Next checks

  • Verify secret versioning and overlap window.
  • Confirm endpoint matches provider environment.
  • Capture raw body bytes and verify locally.

Replays accepted as new events

Likely causes

  • No timestamp validation.
  • No dedupe window on event IDs/delivery IDs.
  • Provider retries + your handler is not idempotent.

Next checks

  • Add max-age checks and allow small skew.
  • Store event IDs/delivery IDs with TTL and reject repeats.
  • Make side effects idempotent (unique constraints, idempotency keys).

Cross-tenant data bleed

Likely causes

  • Secret lookup not bound to tenant/webhook identity.
  • Using a single global secret for all tenants.
  • Routing mistakes (wrong webhook ID -> wrong tenant).

Next checks

  • Derive secrets per webhook/tenant.
  • Bind verification to the webhook ID you are receiving on.
  • Add tests that validate tenant boundaries.
For incident response and triage steps, see monitoring & alerting.

How Hooque helps

Offload the fragile parts: verification, retries, and durable persistence — without exposing your app servers to inbound traffic.

  • Provider-specific signature verification at ingest (Stripe, GitHub, Shopify, Slack, and more).
  • Durable queues per consumer so replays and retries do not hit your app servers directly.
  • Per-consumer API keys (Bearer) for least-privilege access to read/ack/nack/reject.
  • Explicit Ack/Nack/Reject so you control delivery outcomes and can quarantine bad payloads.
  • Inspection + replay surface when debugging auth and replay incidents.

Want to see it in action? Start with monitoring webhooks and check pricing.

FAQ

Security details that tend to break during migrations, refactors, and local development.

What is the best way to secure webhooks?

Use a signature scheme (usually HMAC) verified over the raw request body, validate a timestamp within a max-age window, and make processing idempotent so replays and retries are safe. With Hooque, provider-specific signature verification happens at ingest before messages enter the queue.

Should I use IP allowlisting to secure webhooks?

IP allowlisting can help, but it is brittle because provider IPs change and NAT/CDNs complicate source IPs. Signatures are usually the primary control; allowlists are an optional defense-in-depth layer. With Hooque, you can rely on signature verification at ingest without exposing your app servers directly to inbound traffic.

How do I prevent webhook replay attacks?

Validate timestamps, set a max age (e.g. 5 minutes), and dedupe on provider event IDs or delivery IDs for a window. If a provider does not provide IDs, use a stable hash of the raw payload + key headers. With Hooque, events are persisted and delivered through a queue interface so you can implement dedupe in your worker and use explicit Ack/Nack/Reject outcomes.

How should I rotate webhook secrets?

Keep an overlap window where you accept both the current and previous secret, then revoke the old one. Rotate per tenant/webhook to avoid cross-tenant impact. With Hooque, secrets and verification settings are configured per webhook so rotations do not require redeploying receivers.

Why does signature verification fail in production but work locally?

Common causes are verifying over parsed JSON instead of raw bytes, incorrect secrets (test vs prod), header casing differences, and timestamp validation failing due to clock skew. With Hooque, signature verification is handled in a consistent ingest layer, reducing environment-specific parsing differences.

What status code should I return when verification fails?

Return a 401/403 for authentication failures. Do not return 2xx unless the request is authenticated and you intend to accept the delivery attempt. With Hooque, unauthenticated requests can be rejected at ingest, and only verified messages proceed to consumers.

Start processing webhooks reliably

Secure webhook ingest, durable queues, and explicit delivery controls — without running public endpoints.

Start for free

No credit card required