SYS://FIELD-REPORT-13
Anyone Could POST a Fake Payment to Your Webhook
The app sold courses. Stripe Checkout took the card, Stripe fired a webhook, the webhook granted access. End to end, it worked — I paid with a test card and the course unlocked. Then I looked at the handler. It read the event body, matched on payment_intent.succeeded, and granted access. What it never did was check that the message came from Stripe. I curled the endpoint with a JSON body I typed myself, and it granted access to a course nobody paid for. A webhook is a stranger POSTing to a public URL. This one believed every stranger.
This is a representative audit — the exact pattern I hit over and over on apps built with Lovable, Bolt, v0, and Cursor. Names and paths are changed, but every finding below is real-shaped and drawn straight from the kind of report the launchworthy skill produces. Call the app coursevault: a Next.js app on Stripe Checkout, selling one-time course access, about to go live.
Two things are wrong here, and they compound. One lets a stranger mint access for free. The other hides it when a real buyer gets nothing.
The finding: the handler trusts whoever POSTs to it
A webhook endpoint is a URL on the public internet. The provider signs every request with a secret only you and they share; verifying that signature is the only thing that separates “Stripe told me this” from “someone typed this.” coursevault skipped it:
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const event = await req.json() // parsed, unverified
if (event.type === "payment_intent.succeeded") {
await grantAccess(event.data.object.metadata.userId) // to anyone who asks
}
return new Response("ok")
}
Anyone who finds the URL — it’s in the browser’s network tab, in your repo, in Stripe’s dashboard — can POST {"type":"payment_intent.succeeded","data":{"object":{"metadata":{"userId":"..."}}}} and get a grant. No card, no charge, no trace.
CRITICAL · CAUGHT
Webhook grants access without verifying the signature
app/api/webhooks/stripe/route.ts calls req.json() and acts on the event without constructEvent. Because the handler grants entitlements straight from the event, a forgeable webhook is a free-access generator: anyone who finds the endpoint mints payment_intent.succeeded and gets the product. A missing signature check is a HIGH on its own; when the handler grants, charges, or entitlements from the event, it’s a CRITICAL.
The fix is Stripe’s constructEvent, which needs the raw body — a parsed-then-reserialized body fails verification:
THE FIX
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // raw body, not req.json()
const sig = req.headers.get("stripe-signature")
if (!sig) return new Response("Missing signature", { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response("Invalid signature", { status: 400 }) // forged, rejected
}
// ...handle the verified event
}The signing secret is server-side env only — never a NEXT_PUBLIC_ / VITE_ / PUBLIC_ prefix — and it’s a different value per environment (Stripe issues one per endpoint, and a separate one for stripe listen). On Express, mount express.raw({ type: 'application/json' }) on the webhook route before any global express.json(), or verification fails on a body that’s already been parsed.
The second finding: a 200 that hides a broken sale
The handler had a catch block. It looked responsible. It was the opposite:
try {
await grantAccess(userId) // throws — the user row doesn't exist yet
} catch (e) {
console.log(e) // swallowed
}
return new Response("ok") // Stripe sees 200, marks it delivered, never retries
Stripe reads your status code and nothing else. Return 200 and it marks the event delivered and moves on. So when grantAccess throws, the buyer’s card is charged, the course never unlocks, Stripe’s dashboard shows a clean 200, and your error tracker sees nothing because the error was swallowed. Every signal is green while a paying customer sits locked out.
HIGH · CAUGHT
Catch block swallows the error and returns 200
The handler acknowledges success to Stripe even when fulfillment failed. The provider never retries, the failure never reaches Sentry, and the only person who knows is the customer who paid and got nothing. Report the failure instead — log it and return a 5xx so Stripe retries (it backs off for up to 3 days):
try {
await grantAccess(userId)
} catch (e) {
Sentry.captureException(e, { extra: { eventId: event.id, type: event.type } })
return new Response("Handler failed", { status: 500 }) // Stripe will retry
}
return new Response("ok")One more thing retries force on you: idempotency. Providers deliver at least once and retry on timeouts, so the same event can arrive twice. Dedup on the event id before you fulfill, or a retry double-grants:
const inserted = await db.execute(
sql`insert into processed_webhook_events (id) values (${event.id}) on conflict do nothing`
)
if (inserted.rowCount === 0) return new Response("ok") // already handled
await grantAccess(userId)
The false alarm: “the URL is basically a secret”
The reason the missing signature check survives is a quiet assumption about obscurity.
FALSE POSITIVE
⚠️ Nobody knows the webhook URL, and Stripe shows all 200s, so it's fine.
Two comfortable wrong beliefs in one sentence. The URL isn’t a secret — it’s in your client’s network tab, your git history, and Stripe’s own dashboard, and it’s a fixed, guessable path (/api/webhooks/stripe). Obscurity is not authentication; the signature is. And “Stripe shows all 200s” is not proof the sales worked — it’s the swallowed-catch bug wearing a badge. A wall of 200s proves delivery, not that the grant happened. The two findings hide each other: the missing signature lets fakes in, and the reflexive 200 makes real failures invisible.
Verify by hand
Run stripe listen –forward-to localhost:3000/api/webhooks/stripe, then stripe trigger payment_intent.succeeded, and confirm the side effect happened — the access row, the email — not just a 200. Then POST the endpoint with no signature header and confirm a 400 with no grant. Then force grantAccess to throw and confirm a 5xx, the error in your tracker, and the event marked failed-and-retried in Stripe. Three checks, and the class of bug is closed.
The rest of the punch list
The forgeable webhook is the blocker. The rest is ranked so scope stays honest:
FIX THIS WEEK
After the webhook
- [HIGH] Swallowed-catch 200 on the same handler — fixed alongside the signature check above, but verify the 5xx path actually reaches Sentry.
- [MEDIUM] No idempotency table — a Stripe retry after a timeout double-grants. Add the
processed_webhook_eventsdedup. - [MEDIUM] Fulfillment runs inline in the request and is slow enough to flirt with the function timeout; a timeout reads as failure and triggers retries of half-done work. Ack fast, hand off to a background job.
- [LOW] Events you don’t handle fall through to a 500; return an immediate 200 for those so Stripe doesn’t retry noise at you.
The shape is the point: one finding gives away the product for free, the rest are real reliability work that won’t end the business tonight. Worst-first turns a flat list of four into “verify the signature before launch, then breathe.”
ASK YOURSELF
“It’s been live in test mode and every event is a 200 — isn’t that the proof?” That’s the trap, not the proof. The 200s are what a broken handler and a working handler have in common. The only thing that tells them apart is whether the side effect happened — the row, the email, the access. Check the effect, not the status code, and the green dashboard stops being a comfort you didn’t earn.
Why this keeps happening
Webhooks are the one part of the app you never see while building. You wire up Checkout, you test with a card, the course unlocks, and the demo is perfect — because in the demo you are Stripe, sending real events from a real account. The signature check is invisible when every request is legitimate, and the reflexive return 200 feels like being a good API citizen. AI builders optimize for the path that works on your screen, and forging a request or breaking fulfillment is never on your screen.
That’s the gap launchworthy exists to close. It plays bouncer at the door of production: it knows a webhook that grants without verifying is a CRITICAL and a catch-then-200 is a HIGH, it greps for the signing secret behind a public env prefix, and it hands you the constructEvent block and the 5xx-on-failure rule — not just a note that “webhooks look configured.”
The card cleared. The course unlocked. The dashboard went green. And the endpoint would have handed the same course to anyone who typed a POST.