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.
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.
Signature verification, freshness, and durable receipt uniqueness each reject a different failure mode; none substitutes for the others.
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.
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=400Some 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.
| Case | Receiver response | Processing behavior | Operator action |
|---|---|---|---|
| Valid, new, fresh event | 202 accepted | Queue once and process idempotently | Monitor normal completion. |
| Valid duplicate event ID | 200 duplicate | Do not enqueue again | Investigate only if duplicate volume is unusual. |
| Valid but stale timestamp | 400 stale_delivery | Do not enqueue | Check sender clock or delayed-delivery policy; do not widen the window casually. |
| Invalid signature or format | 401 invalid_delivery | Do not parse or process | Record a safe decision metric; never log the secret or raw sensitive body. |
| Body exceeds the request limit | 413 payload_too_large | Reject before accumulation, or cancel on streamed overflow; do not verify or enqueue | Reduce the payload to the documented contract; keep proxy and platform limits aligned. |
Malformed JSON, null, array, or invalid event ID | 400 invalid_payload | Do not enqueue | Correct the sender payload and issue a newly signed test event. |
| Accepted event, downstream failure | 202 already returned | Retry in the durable worker within its idempotency contract | Use 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.
| Test | Expected result | Evidence | Recovery validation |
|---|---|---|---|
| Valid signed test event | 202, one receipt, one queued job | Correlated receiver and worker IDs | Worker completes one test state change. |
| Same valid event again | 200 duplicate, no additional job | Unique receipt conflict and queue count | Original business state remains singular. |
| Valid signature with old timestamp | 400 stale_delivery, no receipt | Freshness metric and sanitized request ID | Correct test clock; a new signed event is accepted. |
| Altered body or signature | 401 invalid_delivery, body not processed | Verification-failure metric | Re-send provider-generated test event; it verifies normally. |
| Delivery signed with the previous secret, inside vs. after its grace period | 202 inside the window; 401 once expired | Verification metric shows the matched secret | Drop the previous secret at grace-period end. |
| Same signed delivery replayed twice inside the freshness window | First 202, second 200 duplicate or nonce rejection | Single receipt or nonce hit | Confirm one business effect occurred. |
| Worker dependency outage | Receiver still accepts only while durable capacity is safe | Queue age and dead-letter count | Restore dependency and process each receipt once. |
Troubleshooting
| Symptom | Likely cause | Safe check | Recovery |
|---|---|---|---|
| Every delivery has an invalid signature | Framework parsed or altered the body before verification | Confirm the handler uses raw bytes and compare only provider test events | Restore raw-body handling; do not weaken signature checks. |
| Valid test events are stale | Sender and receiver clocks disagree or the wrong timestamp unit is used | Compare UTC clocks and the provider's documented timestamp format | Fix time synchronization or parsing, then issue a newly signed test event. |
| Retries create duplicate business effects | Receipt record and side effect are not transactionally tied or worker is not idempotent | Inspect receipt uniqueness and state-transition key | Add a durable unique constraint and idempotent worker transition before replaying. |
| Webhook sender receives a challenge page | Browser-focused WAF rule affects the callback route | Review route-specific edge action and response content type | Replace it with signature verification, a narrow limit, and a machine-readable denial. |
| Queue age grows after acknowledgements | Downstream dependency is slow or worker capacity is insufficient | Check queue age, worker concurrency, and dependency errors | Pause non-critical work, scale within safe limits, and use the documented replay path. |
| All deliveries fail after a scheduled secret rotation | Receiver checks only the newest secret, not the active set | Confirm the grace-period secret is still in the verification list | Verify against every active secret; confirm the rotation window matches on both sides. |
| Nonce store rejects deliveries that should be new | TTL outlives the freshness window, or the key collides across routes | Compare the TTL to the freshness window and confirm the key is namespaced per route | Shorten the TTL and namespace keys by route and provider. |
Related guides
Authoritative references
- OWASP Web Service Security Cheat Sheet
- OWASP API10: Unsafe Consumption of APIs
- GitHub: Validating webhook deliveries
- Cloudflare: Protect APIs
- Standard Webhooks specification
- Stripe: Webhook endpoints
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