Webhook Safety Review
Install
Save this file as ~/.claude/skills/webhook-safety-review/SKILL.md, or
.claude/skills/webhook-safety-review/SKILL.md to scope it to one repo. Claude
Code auto-discovers it. Invoke with /webhook-safety-review or by asking "is my
Stripe webhook safe?".
Why this exists
A webhook handler is an unauthenticated public endpoint that a stranger can call, which reacts to it by granting entitlements or moving money. Almost every serious bug in one falls into four buckets, and all four are invisible in normal testing because your own test events are well-behaved.
Rank findings by money and access, not by tidiness.
Step 1 — Find every handler
rg --files -g '**/webhook*' -g '**/webhooks/**' -g '**/hooks/**'
rg -ln "stripe|github|slack|shopify|twilio|resend|clerk|paddle" -g '**/route.ts' -g '**/*.py' -g '**/*.go'
For each, note the sender and what it causes: creating an order, granting a licence, sending mail, updating a subscription. That consequence sets the severity of everything below.
Step 2 — Signature verification, before anything else
The single highest-severity check. Confirm, with file and line:
- A signature is verified at all. No verification means anyone who learns
the URL can post a fake
payment_succeededand grant themselves goods. - It runs BEFORE the body is parsed or acted on. Verifying after you have already created a record is decoration.
- It uses the RAW body. Frameworks that auto-parse JSON break signatures silently, because re-serialising changes bytes. Look for the explicit raw-body escape hatch, and be suspicious if you cannot find one.
- It is constant-time.
===on a signature is a timing oracle; expecttimingSafeEqualor the SDK's own verifier. - The secret comes from the environment, is not a fallback default, and is not the publishable/public key by mistake.
- A timestamp is checked where the sender provides one, so a captured request cannot be replayed later.
rg -n "constructEvent|verifySignature|timingSafeEqual|createHmac|x-hub-signature|stripe-signature" -g '**/route.ts'
If verification is missing on a handler that grants anything, stop and report it immediately. Nothing else in this review matters as much.
Step 3 — Idempotency
Senders retry. Assume every event arrives more than once. Stripe retries for days; a network blip mid-response guarantees a duplicate.
For each handler, find what makes reprocessing safe:
- A unique constraint on the event id, or on a natural key like the order id.
- An upsert rather than a create.
- A processed-events table checked before doing the work.
rg -n "@@unique|@unique|ON CONFLICT|upsert|findUnique.*event"
The failure is not theoretical: without this, a retried checkout.completed
creates a second order, a second licence, or a second payout. If you have ever
seen a duplicate charge or a double-granted entitlement, this is where it came
from.
Check the guard is on the RIGHT key. Deduplicating on your own generated id while the sender retries with the same event id protects nothing.
Step 4 — Acknowledge fast, work after
Senders time out, and a timeout is treated as failure, so slow work causes duplicate deliveries and eventually disables the endpoint.
Flag any handler doing heavy work inline before responding: sending email, generating files, calling other APIs, long transactions. The shape you want is verify, record, return 200, then process out of band (a queue, or a platform-native after-response hook).
Step 5 — Return the right status
This is where handlers quietly become incident generators:
- 200 — received and either handled or safely ignored. Return 200 for event types you do not care about, otherwise the sender retries them forever.
- 400 — signature invalid or body malformed. Permanent failure; do not invite a retry.
- 5xx — YOUR transient failure, where you genuinely want a retry.
The classic bug is a handler that throws on an unrecognised event type, returns 500, and gets retried forever until the provider disables the endpoint. The second classic is catching everything and returning 200, so real failures are silently dropped and the sender never retries something you actually needed.
Read the catch blocks specifically. Both mistakes live there.
Step 6 — Check what the handler trusts from the payload
Treat the body as attacker-controlled even after signature verification, because a legitimate sender still sends fields you should not blindly trust.
- Never trust an amount, price, or currency from the payload. Re-read it from your own record or from the provider's API using the id.
- Resolve the user from your stored mapping, not from an email in the body.
- Validate the shape with a schema; do not index into nested fields hopefully.
Step 7 — Report
Handlers reviewed: <n>
CRITICAL
<file:line> <endpoint> — <no signature check | verified after acting | trusts payload amount>
attack: <what a stranger posts, and what they get>
HIGH
<file:line> — not idempotent on <key> -> retry causes <duplicate order/licence/charge>
MEDIUM
<file:line> — returns <status> for <case> -> <infinite retry | silent drop>
<file:line> — <slow work> before responding
VERIFIED SAFE
<file:line> — signature verified pre-parse, idempotent on <key>, correct statuses
For every CRITICAL, write the concrete attack in one sentence. "Anyone who learns this URL can post a fake payment event and receive a paid product for free" gets fixed today.
Rules
- Signature verification before parsing. Everything else is secondary.
- Assume duplicate delivery, always. Idempotency is not optional.
- 200 means "received", not "succeeded". Unknown event types get 200.
- Never trust money amounts from a payload, even a signed one.
- Do not report a handler safe without citing where each of the four properties is enforced.
From Toolbay. Free to use, modify, and share. Keep this line and others can find it too.