Skip to content
SlashClone
Made by@shipcheck

Stripe webhook hardening

@shipcheck/stripe-webhook-hardening

Find your Stripe webhook handlers and harden them: verify the Stripe-Signature header, use the raw body, keep the signing secret in env, process each event once, reject bad signatures with 400, and fulfill orders from the webhook rather than the success page. Reads your code and proposes diffs; never calls Stripe.

SkillDevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1---
  2. 2name: stripe-webhook-hardening
  3. 3description: Review and fix Stripe webhook handlers so payments cannot be faked or double counted. Checks signature verification with the Stripe-Signature header, raw request body, signing secret in env, idempotency on event.id, 400 on bad signatures, event type filtering, edge runtime support, and that fulfillment happens in the webhook rather than on the success page. Use when the user adds Stripe Checkout or subscriptions, asks "is my Stripe webhook secure", sees "No signatures found matching the expected signature", gets duplicate orders, or before launching paid features.
  4. 4license: MIT
  5. 5---
  6. 6
  7. 7# Stripe webhook hardening
  8. 8
  9. 9A Stripe webhook is a public URL that grants paid access. If it skips the signature check, anyone can post a fake "payment succeeded" event. If it is not idempotent, a Stripe retry gives double credits. This skill finds the handlers, checks them, and proposes small, specific diffs.
  10. 11## When to use
  11. 13- After adding Stripe Checkout, Payment Links, subscriptions, or Connect.
  12. 14- "Is my webhook secure?", "signature verification failed", "users got charged once but credited twice".
  13. 15- Before turning on live mode.
  14. 17## Steps
  15. 191. **Scan.**
  16. 21 ```bash
  17. 22 node scripts/webhook-scan.mjs . --json out/webhook-scan.json --md out/webhook-scan.md
  18. 23 ```
  19. 25 If it finds 0 handlers, search the code for `stripe-signature`, `constructEvent`, and `checkout.session.completed`, and ask the user which route their Stripe dashboard points to.
  20. 272. **Read every handler file in full.** The scan is a pointer, not the verdict. Confirm each finding against the code and check what the scan cannot:
  21. 28 - **Fulfillment source:** is access granted on the success page (reading `session_id` from the URL) instead of in the webhook? The success page is user-controlled. It may show "thanks", but only the webhook grants access.
  22. 29 - **Trusting metadata blindly:** amounts and prices should come from the Stripe event or a Stripe API lookup, never from client-sent values.
  23. 30 - **Slow work before responding:** long jobs (emails, AI calls) should be queued so the handler returns 2xx within a few seconds, or Stripe retries.
  24. 31 - **Event coverage:** subscriptions need `customer.subscription.updated` and `.deleted`, and `invoice.payment_failed`, or cancelled users keep access.
  25. 323. **Report** using the format below. High first.
  26. 334. **Propose fixes as diffs**, one finding at a time, and apply each only after the user says yes. Use the patterns below for their framework.
  27. 345. **If idempotency needs a table,** write a new migration (for example `create table processed_events (id text primary key, created_at timestamptz default now())`) and show it. The user applies it.
  28. 356. **Test plan.** Tell the user to run `stripe listen --forward-to localhost:3000/api/webhooks/stripe` and `stripe trigger checkout.session.completed` themselves, then resend the same event from the dashboard to confirm it is processed once.
  29. 37## Fix patterns
  30. 39Next.js App Router:
  31. 41```ts
  32. 42export async function POST(req: Request) {
  33. 43 const body = await req.text(); // raw body, never req.json()
  34. 44 const sig = req.headers.get("stripe-signature") ?? "";
  35. 45 let event: Stripe.Event;
  36. 46 try {
  37. 47 event = stripe.webhooks.constructEvent(body, sig, webhookSecret); // webhookSecret from STRIPE_WEBHOOK_SECRET
  38. 48 } catch {
  39. 49 return new Response("Invalid signature", { status: 400 });
  40. 50 }
  41. 51 // insert event.id into processed_events; on conflict return 200 and stop
  42. 53```
  43. 55Express: register `express.raw({ type: "application/json" })` on the webhook route, before `app.use(express.json())`. Edge or Supabase Edge Functions: `await stripe.webhooks.constructEventAsync(body, sig, secret, undefined, Stripe.createSubtleCryptoProvider())`.
  44. 57## Output format
  45. 59```markdown
  46. 60# Stripe webhooks: <project>
  47. 61Handlers: 1 | High 2 | Medium 1 | Low 0
  48. 63## app/api/webhooks/stripe/route.ts
  49. 64- HIGH signature: no constructEvent. Anyone can fake a paid checkout. Fix: diff 1.
  50. 65- HIGH raw-body: uses req.json(). Fix: diff 1.
  51. 66- MEDIUM idempotency: retries double the credits. Fix: diff 2 plus migration.
  52. 67- Fulfillment: success page also grants access (app/success/page.tsx:14). Fix: diff 3.
  53. 68```
  54. 70## Guardrails
  55. 72- Never call the Stripe API, create webhook endpoints, or roll secrets. Tell the user where to do it in the dashboard.
  56. 73- Never print or paste a signing secret or API key. If one is hardcoded, report the file and line only, and say it must be rolled.
  57. 74- Never "fix" a failing signature by removing verification or by switching to test mode.
  58. 75- Use test mode for every test you suggest. Live mode is the user's call.