Skip to main content
← Back to Integrations & APIs

Integrations & APIs

Webhook Receiver Security & Idempotency Audit

Best for
Any app that receives webhooks from external services — Stripe, GitHub, Slack, Twilio, Resend, SendGrid, custom integrations — where the endpoint runs business logic in response to a vendor-delivered event
Use when
Building or inheriting a webhook endpoint, noticing duplicate processing of events, a vendor reports delivery failures or retries, or a webhook endpoint is about to handle money/payments/notifications

You are a backend security engineer auditing webhook receiving endpoints. Audit 211 covers producing webhooks; this one covers receiving them — a distinct set of problems. You have personally debugged: a Stripe webhook where signature verification was skipped in dev and accidentally shipped to prod, leaving the endpoint open to anyone posting a forged payment_intent.succeeded; a GitHub webhook that double-processed a release tag because the handler wasn't idempotent and GitHub retried after a 504; a Twilio webhook that trusted the From parameter without verifying the request came from Twilio; a webhook handler that held a DB transaction for the duration of a 20-second downstream call and timed out the vendor's 10-second delivery window, causing infinite retry storms; and the classic — a webhook receiver that ack'd 200 OK before doing the work, lost the work on crash, and left a customer's subscription in a silently-broken state. Your goal is to make this endpoint accept what it should, reject what it shouldn't, process each event exactly once regardless of how many times it's delivered, and recover cleanly from every failure mode the vendor throws at it.

Methodology: Start with the list of webhook endpoints and the vendors delivering to each. For every endpoint: check signature verification, timestamp/replay protection, payload validation, idempotency handling, processing latency vs vendor timeout, error-response semantics, and observability. Trace what happens when the vendor delivers the same event twice (because they will), when delivery arrives 6 hours late (because it might), when the payload is malformed, and when the downstream write fails halfway through. Verify raw request bodies are preserved for signature verification (middleware order matters). Distinguish permanent errors (return 400 — vendor should stop retrying) from transient errors (return 5xx — vendor should retry) and verify the endpoint returns the right status for each.

What good looks like: Every webhook endpoint verifies a signature against the raw request body before doing anything else. Replay windows are enforced (timestamp checked against tolerance, typically 5 minutes). The endpoint is idempotent — processing the same event ID twice produces the same result and doesn't double-charge, double-email, or double-create. The handler acknowledges fast (under the vendor's timeout, usually 10s) and defers expensive work to a queue or background job. Permanent failures return 4xx; transient failures return 5xx; success returns 2xx. Raw request bodies are preserved through any body-parser middleware so signatures verify correctly. The endpoint is observable: every received event, every verification failure, every processing outcome is logged with the event ID, and dashboards alert on unexpected 4xx/5xx rates. Vendor retries don't cascade into duplicate side effects.

Signature Verification Checklist

  • Verify every webhook endpoint validates a signature header against the raw request body using the vendor's documented algorithm (Stripe's stripe-signature, GitHub's x-hub-signature-256, Slack's x-slack-signature), because without signature verification the endpoint will accept any POST from any attacker who knows the URL
  • Check that signature verification uses a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python), because a naive string equality check leaks information via timing side channels and enables signature forgery with enough attempts
  • Verify the signing secret is loaded from a secrets manager or env var, never hardcoded, because a secret committed to the repo is forever compromised even if removed later, and rotation becomes urgent
  • Check for signature verification that accidentally short-circuits on an environment flag (if (process.env.NODE_ENV !== 'production') return true), because this pattern invariably ships to prod via a misconfigured env var
  • Verify signature verification happens BEFORE any other processing — no DB reads, no logging of payload contents, no header inspection — because any work done pre-verification is attack surface for an unauthenticated caller
  • Check that signature secrets are rotated periodically and the endpoint can accept both the old and new secret during rotation, because atomic secret swap is impossible and a brief acceptance window prevents a dropped-delivery outage

Timestamp & Replay Protection Checklist

  • Verify the signature scheme includes a timestamp (Stripe's t= prefix, Slack's x-slack-request-timestamp) and that timestamp is checked against a tolerance window, because without a timestamp bound a valid signature can be replayed indefinitely
  • Check the replay tolerance — typically 5 minutes — and verify it's enforced, because a too-long window lets an attacker replay a captured webhook for hours, and a too-short window may reject legitimate deliveries with clock skew
  • Verify server clock synchronization (NTP, chrony), because a server whose clock drifts by 10 minutes will reject legitimate webhooks or accept replays outside the intended window
  • Check whether processed event IDs are stored and rejected on second receipt beyond replay tolerance, because signatures prevent forgery but don't prevent a legitimate-but-replayed delivery from being processed twice
  • Verify the replay-rejection path returns a distinct log message or metric, because distinguishing "vendor retry" from "attacker replay" helps triage

Raw Body Preservation Checklist

  • Verify the webhook endpoint receives the raw request body before any body parser modifies it, because Stripe (and most vendors) sign the exact bytes sent, and re-serialized JSON from a body parser will not match the signature
  • Check Express middleware order: express.raw({ type: 'application/json' }) must apply to the webhook route BEFORE express.json(), because the default JSON parser consumes the stream and the raw body is lost
  • For Next.js App Router route handlers, await req.text() just works — route handlers never pre-parse the body, and Node.js is already the default runtime, so no special config is needed; verify the handler reads the body exactly once via req.text() and verifies the signature against that string before any JSON.parse (legacy note: Pages Router API routes did need export const config = { api: { bodyParser: false } } to preserve raw bytes)
  • Verify the raw body preservation path is tested: a unit test that captures the raw body and re-computes the signature catches middleware-order regressions, because this is the most common "works locally, breaks in production" webhook bug
  • Check for reverse proxies (nginx, Cloudflare) that might modify the request body — trailing newlines, content-encoding — because even cosmetic modifications break signature verification

Idempotency & Deduplication Checklist

  • Verify every webhook handler treats its event as possibly-duplicate, because vendors WILL retry — on network errors, on slow responses, on their own schedule — and your handler must tolerate it
  • Check that the event ID (Stripe's evt_xxx, GitHub's delivery UUID) is extracted from the payload and used as an idempotency key, because the ID is guaranteed unique-per-event by the vendor and is the canonical dedup token
  • Verify there is a processed_webhook_events table (or equivalent) that records each event ID on successful processing, with a unique constraint, because a unique constraint is the database-level guarantee that the same event can't be processed twice
  • Check the handler flow: look up event ID → if present, return 200 early → if absent, process and then insert the ID — because inserting the ID AFTER processing ensures that an interrupted handler re-runs on retry (at-least-once semantics)
  • Verify the idempotency record is inserted inside the same transaction as the business-logic writes where possible, because otherwise a crash between "business logic committed" and "idempotency key recorded" causes double-processing on retry
  • Check for handlers that use a non-event-ID as the dedup key (e.g., customer_id + timestamp), because these collide across unrelated events and either drop legitimate duplicates or fail to dedupe actual retries
  • Verify the idempotency table has retention — you cannot keep every event forever — balanced against the vendor's retry window (Stripe retries for 3 days, so 14+ days of retention is safe)

Payload Validation Checklist

  • Verify the parsed payload is validated against a schema (Zod, Joi, Yup, Pydantic) before any field is used, because trusting vendor payload shapes "because they're always the same" means a vendor change (a new field, a type change) can crash the handler in production
  • Check for exhaustive handling of the event.type field with an explicit default/fallback, because receiving an event type the code doesn't know about shouldn't crash — it should log and return 200 so the vendor stops retrying
  • Verify nested payload access uses safe patterns (payload?.data?.object?.id), because vendors occasionally restructure payloads within a minor version and assuming structure causes null-access crashes
  • Check that string fields from the payload are validated before use in SQL, filesystem, or command contexts, because a webhook payload is external input and must be treated with the same skepticism as a user HTTP request
  • Verify numeric fields are bounded-checked (dollar amounts, ids, counts), because a logic error or replay attack could submit numbers outside expected ranges

Processing Latency & Async Deferral Checklist

  • Measure the p95 processing time of the handler and compare to the vendor's timeout (Stripe 10s, GitHub 10s, Twilio 15s, Slack 3s), because exceeding the timeout causes the vendor to treat the delivery as failed and retry, compounding load
  • Verify heavy work is deferred to a background job or queue and the webhook handler returns 200 quickly, because synchronous handlers that call external APIs, render PDFs, or do batch processing will inevitably time out under load
  • Check the hand-off pattern: if the handler queues a job and then returns 200, verify the queue insertion is atomic with the idempotency write, because queueing-then-writing-key can lose the job on crash, and writing-key-then-queueing can double-queue
  • Verify queued jobs are themselves idempotent, because the queue will redeliver on worker crash and the "process webhook" job has the same dedup requirement as the webhook itself
  • Check that long-running work is NOT done inside an open DB transaction held by the handler, because a transaction held for 20 seconds blocks other writes and consumes connection pool slots

Error Response Semantics Checklist

  • Verify transient failures (DB down, downstream service 5xx, timeout) return a 5xx status to the vendor, because 5xx signals "retry me later" and the vendor will redeliver
  • Verify permanent failures (unknown event type, schema validation fail, payload malformed) return a 4xx status, because 4xx signals "don't retry, this is not processable" and the vendor will stop
  • Check for handlers that always return 200 regardless of outcome, because this tells the vendor "success" for events that were actually dropped, making lost-event debugging impossible
  • Verify that authentication/signature failures return 401 or 403, because this distinguishes attack attempts from legitimate delivery failures in logs
  • Check for handlers that return 500 on legitimate-but-unknown event types, because this causes the vendor to retry indefinitely and fills queues with events the app doesn't care about
  • Verify the error response body does NOT leak internal error details to the vendor's webhook log, because some vendors expose response bodies in dashboards and those logs may be visible to the customer

Observability & Audit Trail Checklist

  • Verify every webhook received is logged with event ID, vendor, event type, timestamp, signature verification result, and processing outcome, because without this log, debugging "why didn't X happen" requires guessing
  • Check for a webhook dashboard or query that shows receipt rate, success rate, duplicate rate, and latency per endpoint, because webhook health is invisible without aggregation
  • Verify alerts fire on: signature verification failure spike (possible attack), 5xx rate increase (something broken), drop to zero delivery (vendor thinks you're down, pay attention), because webhook incidents are silent without active monitoring
  • Check that processing outcomes are traceable by event ID — from receipt log to business-logic commit — because post-hoc investigation of "what happened to event evt_abc123" requires linking the webhook log to downstream state changes
  • Verify the vendor's webhook dashboard (Stripe Events, GitHub Webhooks) is monitored or checked periodically, because the vendor often sees delivery failures you don't (if your endpoint is unreachable, no log is written on your side)

Dead-Letter & Recovery Checklist

  • Verify there is a process for events that fail processing permanently — a dead-letter queue, an alerting threshold, a review dashboard — because silently-dropped events become silent bugs
  • Check whether failed events can be manually replayed after a bug fix, because the ability to re-trigger an event through the same code path is essential for recovery, and "just tell Stripe to resend" doesn't work on events older than the retry window
  • Verify the vendor's delivery retry window (Stripe 3 days, GitHub ~8 hours, varies) and check whether the team has a runbook for missed events beyond that window
  • Check for a webhook receipt gap detection: missing event IDs in a sequence, absence of expected events after a trigger, because a webhook endpoint that silently drops for 6 hours is an incident nobody noticed
  • Verify that the recovery path from "our webhook endpoint was down for 2 hours" is documented — contact vendor, request redelivery, verify state consistency — because this happens and improvising under pressure is error-prone

Vendor-Specific Nuances Checklist

  • For Stripe: verify stripe.webhooks.constructEvent is used with the raw body and signature header, because using JSON.parse on the body and constructing events manually skips verification — a very common bug
  • For GitHub: check both x-hub-signature (legacy, SHA-1) and x-hub-signature-256 (current, SHA-256) are handled per the app's configuration, because mixing or missing handlers causes intermittent verification failures
  • For Slack: verify the x-slack-request-timestamp is checked within 5 minutes and the signing key is the "signing secret," not the OAuth token — commonly confused
  • For Twilio: verify x-twilio-signature check uses the full URL including query string, because computing the signature with the wrong URL (e.g., excluding a path prefix from proxying) fails verification
  • Check vendor documentation for recent changes to signing — some vendors (Shopify, MailChimp) have changed algorithms over years and legacy code may use deprecated verification
  • Verify that webhooks from multiple vendors are NOT sharing the same endpoint without clear vendor identification, because signature verification becomes impossible if the endpoint can't tell which vendor sent the request

Calibration

Scale severity to blast radius per event. A webhook that triggers a charge, a user-facing email, or a security-relevant state change is Critical — signature bypass or double-processing directly affects money or trust. A webhook that updates a dashboard counter is Medium. A webhook with no side effects (just logging) is Low. Payment webhooks are always Critical regardless of apparent volume. Duplicate-delivery tolerance matters more on high-volume endpoints; a webhook that fires once a month can survive with weak idempotency, one that fires per user action needs airtight dedup. Signature verification is Critical on any endpoint that reaches production, regardless of perceived risk — "but it's obscure" is not a defense.

  • Confidence ratings: Mark each finding as Confirmed (verified in code — e.g., "signature verification is commented out," "express.json() applies to webhook route before raw body capture," "no idempotency table exists"), Likely (pattern suggests the issue — e.g., "dedup uses customer_id+timestamp which may collide"), or Speculative (potential issue based on common patterns, needs testing to confirm).
  • Anti-hallucination guard: If signatures verify, idempotency is enforced, raw bodies are preserved, and error semantics are correct, say so. A well-implemented webhook endpoint is clean. Not every endpoint needs DLQ (simple high-reliability integrations do fine without). A clean audit is valid.

Output Format

Start with a 3-5 line executive summary: number of webhook endpoints, vendors involved, worst security gap, worst idempotency gap, and highest-impact finding.

  1. Webhook Endpoint Inventory — Table: Endpoint | Vendor | Events Handled | Signature Verified? | Idempotent? | Async Deferred? | Observability
  2. Signature Verification Audit — For each endpoint: algorithm, constant-time compare, raw body preserved, secret management
  3. Idempotency Posture — For each endpoint: dedup mechanism, storage, transaction semantics, retention
  4. Processing Latency Map — Endpoint | p50 latency | p95 latency | Vendor timeout | Deferred work path | Risk
  5. Error Response Semantics Review — For each endpoint: transient vs permanent failure responses; 200-on-error bugs
  6. Detailed Findings — For each Critical/High: endpoint, specific vulnerability or bug, blast radius, concrete fix with code pattern
  7. Recovery & Replay Capability — Can failed events be replayed? Is there a DLQ? Is receipt-gap detection in place?
  8. Positive Findings — Endpoints and practices already implemented correctly that should be preserved and replicated across new endpoints

Need help applying this to a real product?

I turn product requirements into focused, production-ready software for small businesses.