You can fix insecure code with a diff. You cannot fix an insecure design with a diff. This is the category where the checkout flow works, the API returns 200, the tests pass — and someone discovers they can order a thousand dollars of product for zero dollars forever, because nothing in the flow was wrong, only the flow itself.
Insecure design is the category for bugs you cannot grep for. The code is fine on its own — every function does what it says. The problem is that the flow they add up to has a hole in it. A payment that settles optimistically, a reset token that never expires, a quota that resets on every request. The fix is a rethink, not a patch.
What your AI actually built
You asked for a checkout that takes a Stripe payment, writes the order to the database, and sends a confirmation email. The model gave you exactly that. The frontend calls /api/checkout, the route creates the order, the user sees the success page.
What the model did not ship was the state machine. The order is marked paid as soon as the row is written. The webhook that actually confirms the charge runs later, updates the same row, and nobody notices that for the first fifteen seconds the order was already treated as fulfilled. A race condition nobody wrote on purpose — because nobody designed the flow to have one state at all.
The same class of bug hides in password resets that do not expire, in coupon codes that stack, in rate limits that count failures but not successes, in multi-step forms where step three trusts the hidden field from step two. The code is fine. The plan is broken.
How it gets exploited
The attacker notices the success page fires before the Stripe webhook does.
- 1Start the checkoutThey add an item, click pay, and let the frontend show the success page. The order is already in the database as paid.
- 2Kill the paymentThey close the tab before Stripe confirms the charge. The webhook never fires. The order stays marked paid with no corresponding charge.
- 3Trigger fulfilmentThe worker reads the paid row and ships the digital good, or schedules the physical one, or unlocks the subscription.
- 4Repeat foreverThe flaw is structural, not rate-limited. They write a script. Every order is free.
The attacker has a zero-dollar firehose of real product. Nothing in the logs looks unusual until someone reconciles Stripe against the orders table at the end of the month.
Vulnerable vs Fixed
// app/api/checkout/route.ts
export async function POST(req: Request) {
const { items } = await req.json();
const order = await db.order.create({
data: {
items,
status: 'paid', // optimistic: settle it in the webhook
total: totalFor(items),
},
});
return Response.json({ orderId: order.id });
}// app/api/checkout/route.ts
export async function POST(req: Request) {
const { items } = await req.json();
const order = await db.order.create({
data: {
items,
status: 'pending',
total: totalFor(items),
},
});
// fulfillment waits for the Stripe webhook to move
// status: 'pending' -> 'paid' before anything ships.
return Response.json({ orderId: order.id });
}One string change and a comment — but the real fix is the state machine around it. Nothing fulfils a pending order. The webhook is the only thing that flips it to paid. The design is what makes the code safe.
A real case
A Stripe webhook race turned a SaaS into a zero-dollar store
We walked through this exact class of bug on a real app — the checkout returned success before Stripe ever confirmed the charge, and the fulfilment worker never noticed.
Read the case studyRelated reading
References
Find the bugs no code review would ever catch.
Flowpatrol walks your flows the way an attacker would — out of order, twice at once, half-finished — and tells you where the plan fell apart.
Try it free