Webhook Receiver Hardener
Turn an inbound webhook route into hostile-input-safe code: verify before you trust, persist before you ack, ack before you process.
A webhook endpoint is an unauthenticated, internet-facing write path that an attacker can spam and a flaky sender will hammer with retries. Treat every byte as untrusted until the signature checks out.
Workflow
- Capture the raw body. Read the exact request bytes before any JSON parse or body-parsing middleware runs. A re-encoded body changes whitespace and ordering and silently breaks the HMAC. If the framework buffers/reserializes by default, disable it for this route.
- Verify the signature. Compute the HMAC over the raw bytes with the signing secret loaded from config. Compare with a constant-time equality function - never
== or string compare. Support two active secrets so secret rotation does not drop traffic. Reject failures with 401 before doing any other work.
- Reject replays by timestamp. Most providers sign a timestamp alongside the payload. Verify it is part of the signed material, then reject requests whose timestamp falls outside a tolerance window (commonly 5 minutes) to blunt captured-request replay.
- Persist the raw event, then enqueue, then ack. Synchronously do only the minimum: store the verified raw payload and its event ID durably, enqueue a background job, return 2xx. Return success only after the event is durably stored so a crash mid-processing causes safe redelivery rather than silent loss.
- Process asynchronously and order-tolerantly. Run all business logic in the background worker, not the request. Webhooks arrive out of order, so never assume sequence: act on the event's own version/timestamp, or refetch current state from the provider's API rather than mutating from a possibly-stale payload. For strict ordering, partition the queue by resource ID.
- Return the right status. 2xx tells the sender to stop retrying; non-2xx requests a retry. A malformed-but-authentic event goes to a dead-letter queue and is acked, not retried forever. Log event ID and type on every receipt.
Worked example
The classic broken handler versus the hardened one (Express + Stripe-style signatures):
Bad:
app.use(express.json()); // global parser destroys the raw body
app.post("/webhooks/stripe", async (req, res) => {
if (req.headers["stripe-signature"] !== computeSig(JSON.stringify(req.body))) {
return res.status(401).end(); // re-serialized body ≠ signed bytes; string compare leaks timing
}
await fulfillOrder(req.body); // business logic inside the request
await sendReceiptEmail(req.body); // slow third-party call before the ack
res.status(200).end(); // sender already timed out and is retrying
});
Three failures: the HMAC is computed over a re-encoded body (verification breaks or, worse, gets disabled "temporarily"), !== is not constant-time, and the ack waits on business logic - so the sender times out, retries, and double-fulfills.
Good:
app.post("/webhooks/stripe",
express.raw({ type: "application/json" }), // raw bytes, this route only
async (req, res) => {
const sig = req.headers["stripe-signature"];
if (!verifySignatureConstantTime(req.body, sig, [SECRET_CURRENT, SECRET_PREVIOUS])) {
return res.status(401).end();
}
if (!timestampWithinTolerance(sig, 300)) { // 5-minute replay window
return res.status(401).end();
}
const event = JSON.parse(req.body); // parse only after verification
await store.saveRawEvent(event.id, req.body); // durable first
await queue.enqueue("webhook", { eventId: event.id });
res.status(200).end(); // ack in milliseconds; work happens in the worker
});
Quality bar
- Signature is verified against raw bytes with a constant-time compare; tampered or unsigned requests get 401 and touch nothing else.
- Two signing secrets are accepted during rotation; neither is hardcoded.
- Replays outside the timestamp window are rejected.
- The synchronous path is verify → persist → enqueue → 2xx, with no business logic, downstream calls, or blocking DB writes beyond the durable store.
- A process crash after ack cannot lose the event; redelivery re-runs it safely.
Deliverable
Produce a hardened webhook receiver consisting of:
- The route handler implementing the verify → persist → enqueue → ack pipeline, with raw-body capture scoped to this route.
- The verification module: constant-time HMAC check over raw bytes, dual-secret rotation support, and timestamp-window replay rejection.
- The background worker skeleton that loads the stored raw event by ID, processes order-tolerantly, and routes poison events to a dead-letter queue.
- A checklist of what was hardened - each Quality bar item marked verified, plus any provider-specific notes (header names, tolerance window, retry policy).
Do NOT
- Do NOT parse JSON or run middleware before computing the HMAC over the raw body.
- Do NOT compare signatures with
== or ordinary string equality - use a constant-time compare.
- Do NOT run business logic, call other services, or block on slow DB work inside the request; slow acks trigger sender timeouts and a retry storm.
- Do NOT return 2xx before the event is durably persisted or enqueued.
- Do NOT trust the payload's field order or arrival order to imply event sequence.
- Do NOT disable signature verification for any third-party webhook, even in staging or for local convenience.
- Do NOT use when the call is an internal, mutually-authenticated service-to-service request over mTLS - that channel may not need application-layer HMAC.
- Do NOT use this skill to design the dedup-key mechanism that makes downstream handlers idempotent (which event-ID column, where it lives, how effects key off it) - use idempotency-enforcer instead. This skill stores the raw event and acks; idempotency-enforcer owns deduplication semantics.
1---2name: webhook-receiver-hardener3description: Hardens an inbound webhook endpoint so it verifies the sender signature on the raw body, resists replays, and acknowledges fast by persisting-then-enqueueing before any processing. Use when building or reviewing a handler that receives webhooks from Stripe, GitHub, or any third party, when adding HMAC signature verification, or when a sender is replaying events or hammering you with retries after slow acks.4---5# Webhook Receiver Hardener67Turn an inbound webhook route into hostile-input-safe code: verify before you trust, persist before you ack, ack before you process.89A webhook endpoint is an unauthenticated, internet-facing write path that an attacker can spam and a flaky sender will hammer with retries. Treat every byte as untrusted until the signature checks out.1011## Workflow12131. **Capture the raw body.** Read the exact request bytes before any JSON parse or body-parsing middleware runs. A re-encoded body changes whitespace and ordering and silently breaks the HMAC. If the framework buffers/reserializes by default, disable it for this route.142. **Verify the signature.** Compute the HMAC over the raw bytes with the signing secret loaded from config. Compare with a constant-time equality function - never `==` or string compare. Support two active secrets so secret rotation does not drop traffic. Reject failures with 401 before doing any other work.153. **Reject replays by timestamp.** Most providers sign a timestamp alongside the payload. Verify it is part of the signed material, then reject requests whose timestamp falls outside a tolerance window (commonly 5 minutes) to blunt captured-request replay.164. **Persist the raw event, then enqueue, then ack.** Synchronously do only the minimum: store the verified raw payload and its event ID durably, enqueue a background job, return 2xx. Return success only after the event is durably stored so a crash mid-processing causes safe redelivery rather than silent loss.175. **Process asynchronously and order-tolerantly.** Run all business logic in the background worker, not the request. Webhooks arrive out of order, so never assume sequence: act on the event's own version/timestamp, or refetch current state from the provider's API rather than mutating from a possibly-stale payload. For strict ordering, partition the queue by resource ID.186. **Return the right status.** 2xx tells the sender to stop retrying; non-2xx requests a retry. A malformed-but-authentic event goes to a dead-letter queue and is acked, not retried forever. Log event ID and type on every receipt.1920## Worked example2122The classic broken handler versus the hardened one (Express + Stripe-style signatures):2324**Bad:**2526```js27app.use(express.json()); // global parser destroys the raw body2829app.post("/webhooks/stripe", async (req, res) => {30 if (req.headers["stripe-signature"] !== computeSig(JSON.stringify(req.body))) {31 return res.status(401).end(); // re-serialized body ≠ signed bytes; string compare leaks timing32 }33 await fulfillOrder(req.body); // business logic inside the request34 await sendReceiptEmail(req.body); // slow third-party call before the ack35 res.status(200).end(); // sender already timed out and is retrying36});37```3839Three failures: the HMAC is computed over a re-encoded body (verification breaks or, worse, gets disabled "temporarily"), `!==` is not constant-time, and the ack waits on business logic - so the sender times out, retries, and double-fulfills.4041**Good:**4243```js44app.post("/webhooks/stripe",45 express.raw({ type: "application/json" }), // raw bytes, this route only46 async (req, res) => {47 const sig = req.headers["stripe-signature"];48 if (!verifySignatureConstantTime(req.body, sig, [SECRET_CURRENT, SECRET_PREVIOUS])) {49 return res.status(401).end();50 }51 if (!timestampWithinTolerance(sig, 300)) { // 5-minute replay window52 return res.status(401).end();53 }54 const event = JSON.parse(req.body); // parse only after verification55 await store.saveRawEvent(event.id, req.body); // durable first56 await queue.enqueue("webhook", { eventId: event.id });57 res.status(200).end(); // ack in milliseconds; work happens in the worker58 });59```6061## Quality bar6263- Signature is verified against raw bytes with a constant-time compare; tampered or unsigned requests get 401 and touch nothing else.64- Two signing secrets are accepted during rotation; neither is hardcoded.65- Replays outside the timestamp window are rejected.66- The synchronous path is verify → persist → enqueue → 2xx, with no business logic, downstream calls, or blocking DB writes beyond the durable store.67- A process crash after ack cannot lose the event; redelivery re-runs it safely.6869## Deliverable7071Produce a hardened webhook receiver consisting of:72731. **The route handler** implementing the verify → persist → enqueue → ack pipeline, with raw-body capture scoped to this route.742. **The verification module**: constant-time HMAC check over raw bytes, dual-secret rotation support, and timestamp-window replay rejection.753. **The background worker skeleton** that loads the stored raw event by ID, processes order-tolerantly, and routes poison events to a dead-letter queue.764. **A checklist of what was hardened** - each Quality bar item marked verified, plus any provider-specific notes (header names, tolerance window, retry policy).7778## Do NOT7980- Do NOT parse JSON or run middleware before computing the HMAC over the raw body.81- Do NOT compare signatures with `==` or ordinary string equality - use a constant-time compare.82- Do NOT run business logic, call other services, or block on slow DB work inside the request; slow acks trigger sender timeouts and a retry storm.83- Do NOT return 2xx before the event is durably persisted or enqueued.84- Do NOT trust the payload's field order or arrival order to imply event sequence.85- Do NOT disable signature verification for any third-party webhook, even in staging or for local convenience.86- Do NOT use when the call is an internal, mutually-authenticated service-to-service request over mTLS - that channel may not need application-layer HMAC.87- Do NOT use this skill to design the dedup-key mechanism that makes downstream handlers idempotent (which event-ID column, where it lives, how effects key off it) - use idempotency-enforcer instead. This skill stores the raw event and acks; idempotency-enforcer owns deduplication semantics.