Security guide

Secure Webhooks: Verify, Replay-Protect, Monitor

A webhook endpoint is a public API. Verify the exact signed bytes, reject stale or duplicate deliveries, and make every business effect safe to repeat.

Published
Updated
Reading time
16 min read
On this page

Webhooks avoid polling, but they allow an external system to request a business action. A WAF, CDN, or IP policy may reduce unwanted traffic; none proves a delivery is authentic. The receiver must verify the provider's documented signature before parsing the body, enforce freshness, and durably make the resulting work idempotent.

When a managed edge orchestration layer sits in front of dozens of origins and payment, commerce, and SaaS integrations, one unverified webhook route is enough to let a forged delivery through. Treating signature verification, replay protection, and idempotent processing as a single discipline, enforced the same way across every provider, is what keeps that scale safe rather than merely fast.

Overview

Outcome

Implement a bounded receiver that authenticates the raw payload, distinguishes invalid, stale, duplicate, and accepted deliveries, and can recover processing without repeating a business action.

Overview

Prerequisites

  • The sender's current signature specification, including signed fields, encoding, timestamp unit, and key-rotation behavior.
  • A secret manager or public-key source; never place a production secret in source, browser configuration, logs, or fixtures.
  • A durable receipt and idempotency store with a unique event ID constraint.
  • A queue or worker for effects that do not need to finish before acknowledgement.

Why secure webhooks converge on the same signing pattern

Provider contracts differ in header names, but secure webhooks converge on one pattern almost universally: an HMAC-SHA-256 signature over a timestamp and the raw body, checked with a constant-time comparison, accepted only inside a five-minute freshness window. Stripe signs timestamp + "." + payload in Stripe-Signature; GitHub signs the raw payload alone in X-Hub-Signature-256; the open Standard Webhooks specification (Svix and others) signs id + "." + timestamp + "." + payload in webhook-signature, with the delivery ID doubling as the idempotency key. Same shape, same five-minute reference tolerance.

Acme's contract — timestamp + "." + body, signed with HMAC-SHA-256 — follows that shape too. Code that verifies one provider correctly, with header names and secret source made configurable, verifies the next.

Scenario: Acme Shop receives a payment-status event

Acme Shop accepts payment.updated notifications from its payment provider at /webhooks/payments. The provider documents a signed message of timestamp + "." + raw request body, signed with HMAC-SHA-256 and sent in X-Acme-Signature: sha256=<lowercase-hex>. This is an example contract: use a real provider's exact documented headers and signed-message format rather than adapting it by appearance.

Acme reads the bytes once, verifies the HMAC in constant time, accepts only timestamps within five minutes, and writes the event ID to a durable receipt table in the same transaction as its queue record. The worker reconciles payment state using the provider's authoritative API where the event can be out of order. The provider also rotates Acme's signing secret every 90 days with a 24-hour overlap window, so the receiver checks each delivery against whichever secrets are currently active, not a single hardcoded value.

Verify before parsing, then accept once

Signature verification, freshness, and durable receipt uniqueness each reject a different failure mode; none substitutes for the others.

Download:PNGSVG

1. Webhook signature verification: verify raw bytes before parsing JSON

The implementation below uses the standard Node.js crypto module and the web Request API. It does not call request.json() or convert the raw body to text before verification, and does not compare an attacker-controlled signature until its expected fixed hex format and length are known. The streaming reader stops before accumulating more than the configured limit and cancels the body on overflow. The placeholder environment variable name is not a secret.

import { createHmac, timingSafeEqual } from "node:crypto"

const MAX_BODY_BYTES = 1_048_576
const ACCEPTED_AGE_SECONDS = 300

function signatureBytes(value: string | null): Buffer | null {
  const prefix = "sha256="
  const hex = value?.startsWith(prefix) ? value.slice(prefix.length) : ""
  return /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null
}

async function readRawBody(request: Request): Promise<Buffer | "too_large" | "invalid"> {
  const reader = request.body?.getReader()
  if (!reader) return Buffer.alloc(0)

  const chunks: Uint8Array[] = []
  let length = 0
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) return Buffer.concat(chunks, length)

      length += value.byteLength
      if (length > MAX_BODY_BYTES) {
        await reader.cancel("payload too large").catch(() => undefined)
        return "too_large"
      }
      chunks.push(value)
    }
  } catch {
    return "invalid"
  } finally {
    reader.releaseLock()
  }
}

function isWebhookEvent(value: unknown): value is { id: string; type?: unknown } {
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false
  const id = (value as Record<string, unknown>).id
  return typeof id === "string" && id.trim().length > 0 && id.length <= 256
}

export async function POST(request: Request): Promise<Response> {
  const declaredLength = Number(request.headers.get("content-length"))
  if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
    return Response.json({ error: "payload_too_large" }, { status: 413 })
  }

  const timestamp = request.headers.get("x-acme-timestamp")
  const timestampSeconds = timestamp && /^\d{10}$/.test(timestamp) ? Number(timestamp) : NaN
  const supplied = signatureBytes(request.headers.get("x-acme-signature"))
  const rawBody = await readRawBody(request)

  if (rawBody === "too_large") {
    return Response.json({ error: "payload_too_large" }, { status: 413 })
  }
  if (rawBody === "invalid") {
    return Response.json({ error: "invalid_payload" }, { status: 400 })
  }
  if (!Number.isSafeInteger(timestampSeconds) || supplied === null) {
    return Response.json({ error: "invalid_delivery" }, { status: 401 })
  }

  const expected = createHmac("sha256", process.env.ACME_WEBHOOK_SECRET!)
    .update(`${timestamp}.`, "utf8")
    .update(rawBody)
    .digest()

  if (!timingSafeEqual(expected, supplied)) {
    return Response.json({ error: "invalid_delivery" }, { status: 401 })
  }

  if (Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > ACCEPTED_AGE_SECONDS) {
    return Response.json({ error: "stale_delivery" }, { status: 400 })
  }

  let event: unknown
  try {
    event = JSON.parse(rawBody.toString("utf8"))
  } catch {
    return Response.json({ error: "invalid_payload" }, { status: 400 })
  }

  if (!isWebhookEvent(event)) {
    return Response.json({ error: "invalid_payload" }, { status: 400 })
  }
  if (event.type !== "payment.updated") {
    return Response.json({ error: "unsupported_event" }, { status: 400 })
  }

  const receipt = await persistReceiptAndQueue(event.id, rawBody)
  return Response.json({ status: receipt === "duplicate" ? "duplicate" : "accepted" }, {
    status: receipt === "duplicate" ? 200 : 202,
  })
}

persistReceiptAndQueue must be a database transaction or equivalent durable operation: insert the provider event ID under a unique constraint, enqueue the work only when that insert succeeds, and report duplicate on the uniqueness conflict. It must not be an in-memory map. Enforce the same request-size limit at the proxy or hosting platform as defense in depth, so oversized requests are rejected before they consume runtime capacity.

Representative receiver output
delivery_id=evt_9a1 request_id=req_01H... verification=valid freshness=fresh
receipt=inserted queue=accepted response=202

delivery_id=evt_9a1 request_id=req_01J... verification=valid freshness=fresh
receipt=duplicate queue=not_enqueued response=200

delivery_id=unknown request_id=req_01K... verification=invalid response=401

delivery_id=unknown request_id=req_01L... body=too_large response=413

delivery_id=unknown request_id=req_01M... verification=valid payload=invalid response=400

Some providers send more than one signature in the same header — a space-delimited list of <version>,<signature> pairs, to support rotation or an algorithm migration. Iterate only over versions your receiver implements and accept once any one matches; never let the request choose the algorithm or accept an unrecognized version by default. Node's timingSafeEqual also throws if the two buffers differ in length, so a length check must precede every comparison, not follow it.

2. Rotate signing secrets without breaking verification

A webhook secret is a long-lived credential with no built-in expiry: rotate it on a fixed schedule and immediately after any suspected exposure. A single hardcoded secret comparison makes rotation an outage, since the provider and receiver cannot update atomically — there is always a window where deliveries are signed with a secret the other side has not received yet. Handle it the way major providers do: keep the previous secret valid for a bounded grace period (Stripe keeps a rolled secret active 24 hours; Acme's provider uses the same window) and verify each delivery against every currently active secret, not just the newest.

import { createHmac, timingSafeEqual } from "node:crypto"

async function verifyAgainstActiveSecrets(
  signedMessage: string,
  supplied: Buffer,
  activeSecrets: readonly string[]
): Promise<boolean> {
  for (const secret of activeSecrets) {
    const expected = createHmac("sha256", secret).update(signedMessage).digest()
    if (expected.length === supplied.length && timingSafeEqual(expected, supplied)) {
      return true
    }
  }
  return false
}

Load activeSecrets from the secret manager rather than a single-value environment variable, and drop the previous secret once its grace period ends. Alert if a delivery ever verifies against the previous secret after that point — that usually means provider-side rotation did not complete on schedule. A fixed cadence, rather than rotation only after a suspected leak, keeps the blast radius of any one exposed secret small and keeps the procedure itself exercised before an incident forces you to rely on it.

3. Make acceptance, processing, and recovery separate states

An accepted delivery is not necessarily a completed business action. Acme returns 202 after its durable receipt and queue write, then processes the event asynchronously. The worker verifies that the event is still relevant, applies an idempotent state transition, and records the outcome. A provider retry after a network timeout should find the same receipt and must not create a duplicate shipment, credit, or email.

CaseReceiver responseProcessing behaviorOperator action
Valid, new, fresh event202 acceptedQueue once and process idempotentlyMonitor normal completion.
Valid duplicate event ID200 duplicateDo not enqueue againInvestigate only if duplicate volume is unusual.
Valid but stale timestamp400 stale_deliveryDo not enqueueCheck sender clock or delayed-delivery policy; do not widen the window casually.
Invalid signature or format401 invalid_deliveryDo not parse or processRecord a safe decision metric; never log the secret or raw sensitive body.
Body exceeds the request limit413 payload_too_largeReject before accumulation, or cancel on streamed overflow; do not verify or enqueueReduce the payload to the documented contract; keep proxy and platform limits aligned.
Malformed JSON, null, array, or invalid event ID400 invalid_payloadDo not enqueueCorrect the sender payload and issue a newly signed test event.
Accepted event, downstream failure202 already returnedRetry in the durable worker within its idempotency contractUse dead-letter review and replay only the stored, authorized event.

The freshness window bounds replay risk but does not replace durable idempotency. If the provider supplies a nonce or delivery ID, use its documented retention and uniqueness semantics. Do not assume delivery order; fetch the authoritative current state when an older event could reverse a newer one.

4. Strengthen webhook replay protection with a nonce store

A five-minute freshness window bounds how long a captured delivery stays replayable; it does not eliminate replay inside that window. A validly signed request captured from a proxy hop or a provider-side incident still produces a valid signature and timestamp on replay. Acme's receipt table already defeats that case, since the event ID is unique per business event and enforced with a database constraint before any queue write — that is real webhook replay protection, not just a freshness check, and the two controls complement rather than substitute for each other.

A dedicated nonce store ahead of that receipt table earns its keep in two cases: a provider contract with no unique event ID (rare, but possible on older or internal systems — derive one by hashing the signed message and store it with a TTL matched to the freshness window, in a fast store using atomic "set if not exists"), and a receipt table sitting behind a queue or batched writer, where a nonce check ahead of it closes the race where two replayed copies of a delivery both pass the receipt check before either commits. Keep the TTL no longer than the freshness window; it exists to catch in-window replays, not to become a second, less durable idempotency store.

5. Apply edge and origin controls without breaking the sender

Use a narrow route policy: HTTPS, POST, documented content type, body-size limit, provider-aware rate and concurrency limits, and a predictable machine-readable denial. Browser challenges are not a safe general webhook control because a provider cannot complete them. Keep the origin private or default-deny direct public access, authenticate the edge-to-origin path, and trust forwarded client headers only from that path.

Some providers publish the IP ranges their webhooks originate from. Treat an allowlist from that list as a secondary signal alongside signature verification, never a substitute — providers retire ranges with little notice, and a stale range should surface as its own symptom, not a wave of apparently forged deliveries.

Log only the data needed to operate the receiver: request ID, safe provider delivery ID, event type, verification outcome, freshness outcome, receipt state, queue result, and response status. Redact signatures, authorization headers, cookies, full bodies, personal data, and order details.

Provider-by-provider verification drifts unless someone watches the whole set

A single receiver is easy to keep correct. A business running webhooks from several providers behind one edge is not — each changes its signature format or IP ranges on its own schedule, and a silent regression looks like normal traffic until deliveries fail. That is the cross-provider consistency problem MYO surfaces: verification, freshness, and duplicate-rate metrics correlated per route and provider in one place, not separate dashboards nobody checks until a delivery goes missing.

6. Validate success, failure, and recovery

Exercise these cases with a non-production endpoint and provider test facilities. Use a test secret supplied through approved secret management, not a production value. Prefer a provider's own replay tooling — CLI forwarding, or a dashboard "redeliver" action — over hand-crafted signed requests for routine tests, since a hand-rolled payload drifts from the real contract exactly when the provider updates it. Reserve hand-crafted requests for cases a replay tool cannot produce, such as an expired timestamp or a secret that has just left its rotation grace period.

TestExpected resultEvidenceRecovery validation
Valid signed test event202, one receipt, one queued jobCorrelated receiver and worker IDsWorker completes one test state change.
Same valid event again200 duplicate, no additional jobUnique receipt conflict and queue countOriginal business state remains singular.
Valid signature with old timestamp400 stale_delivery, no receiptFreshness metric and sanitized request IDCorrect test clock; a new signed event is accepted.
Altered body or signature401 invalid_delivery, body not processedVerification-failure metricRe-send provider-generated test event; it verifies normally.
Delivery signed with the previous secret, inside vs. after its grace period202 inside the window; 401 once expiredVerification metric shows the matched secretDrop the previous secret at grace-period end.
Same signed delivery replayed twice inside the freshness windowFirst 202, second 200 duplicate or nonce rejectionSingle receipt or nonce hitConfirm one business effect occurred.
Worker dependency outageReceiver still accepts only while durable capacity is safeQueue age and dead-letter countRestore dependency and process each receipt once.

Troubleshooting

SymptomLikely causeSafe checkRecovery
Every delivery has an invalid signatureFramework parsed or altered the body before verificationConfirm the handler uses raw bytes and compare only provider test eventsRestore raw-body handling; do not weaken signature checks.
Valid test events are staleSender and receiver clocks disagree or the wrong timestamp unit is usedCompare UTC clocks and the provider's documented timestamp formatFix time synchronization or parsing, then issue a newly signed test event.
Retries create duplicate business effectsReceipt record and side effect are not transactionally tied or worker is not idempotentInspect receipt uniqueness and state-transition keyAdd a durable unique constraint and idempotent worker transition before replaying.
Webhook sender receives a challenge pageBrowser-focused WAF rule affects the callback routeReview route-specific edge action and response content typeReplace it with signature verification, a narrow limit, and a machine-readable denial.
Queue age grows after acknowledgementsDownstream dependency is slow or worker capacity is insufficientCheck queue age, worker concurrency, and dependency errorsPause non-critical work, scale within safe limits, and use the documented replay path.
All deliveries fail after a scheduled secret rotationReceiver checks only the newest secret, not the active setConfirm the grace-period secret is still in the verification listVerify against every active secret; confirm the rotation window matches on both sides.
Nonce store rejects deliveries that should be newTTL outlives the freshness window, or the key collides across routesCompare the TTL to the freshness window and confirm the key is namespaced per routeShorten the TTL and namespace keys by route and provider.

Authoritative references

Keep every webhook receiver verified, not just the first one

Talk to Optimi about consistent webhook signature verification, replay protection, and MYO visibility across every provider and route in your stack.

Review webhook security