• Agents
  • Pricing
  • Blog
Log in
Get started

Security for apps built with AI. Paste a URL, get a report, fix what matters.

Product

  • How it works
  • What we find
  • Pricing
  • Agents
  • MCP Server
  • CLI
  • GitHub Action

Resources

  • Guides
  • Blog
  • Docs
  • OWASP Top 10
  • Glossary
  • FAQ

Security

  • Supabase Security
  • Next.js Security
  • Lovable Security
  • Cursor Security
  • Bolt Security

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • Imprint
© 2026 Flowpatrol. All rights reserved.
Home/OWASP Top 10/Web Top 10/A04: Insecure Design
A04CWE-209CWE-256CWE-501

The bug nobody designed away
Insecure Design

The bug where every line of code is correct and the whole thing still does the wrong thing.

One of the most common categories in tested apps.

Reference: Web Top 10 (2021) — A04·Last updated April 7, 2026·By Flowpatrol Team
Insecure Design illustration

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.

  1. 1
    Start the checkout
    They add an item, click pay, and let the frontend show the success page. The order is already in the database as paid.
  2. 2
    Kill the payment
    They close the tab before Stripe confirms the charge. The webhook never fires. The order stays marked paid with no corresponding charge.
  3. 3
    Trigger fulfilment
    The worker reads the paid row and ships the digital good, or schedules the physical one, or unlocks the subscription.
  4. 4
    Repeat forever
    The 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

Vulnerable — trust the client, settle later
// 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 });
}
Fixed — payment confirms the state
// 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 study

Related reading

Glossary

Unprotected Object Binding (Mass Assignment)TOCTOU / Double-Spend (Race Condition)Missing Admin Checks (Broken Function Level Authorization)

What we find

business logic bugs

From the blog

zero dollar forever stripe webhook walkthrough

References

  • A04: Insecure Design — official OWASP entry
  • OWASP Top 10 for Web Applications (2021) — full list
  • CWE-209 on cwe.mitre.org
  • CWE-256 on cwe.mitre.org
  • CWE-501 on cwe.mitre.org

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