The idea: a flaw in the design, not a bug in the code

Insecure design is item A06 of the OWASP Top 10:2025. It covers weaknesses that come from what the application was designed to allow, not from a mistake in how it was written. A perfect implementation of a flawed design is still vulnerable: no amount of input validation or code review fixes a checkout that was designed to take the price from the browser.

A 2 by 2 grid of design (secure, flawed) against implementation (secure, buggy): only secure design with secure code is safe; flawed design with perfect code is still vulnerable, and that cell is what A06 is about
Secure code can't save a flawed design: the fix has to be a design control.

Every attack on this page sends ordinary, well-formed requests from a logged-in or anonymous user. The handler code in the app box does exactly what it says. Each attack runs against the vulnerable design and then against the fixed design, which adds one design control: a business rule the server owns, an atomic operation, a limit, a safe recovery channel, or a state machine. The design rules panel shows which rule is missing (✗) or enforced (✓).

OWASP maps A06 to many CWEs. The ones the page shows or mentions:

CWENameOn this page
CWE-602Client-Side Enforcement of Server-Side Securitythe price in the request (demo 1)
CWE-840Business Logic Errorsdemos 1 and 5
CWE-362, CWE-367Race condition; time-of-check time-of-usethe gift card (demo 2)
CWE-307, CWE-799No limit on authentication attempts; improper control of interaction frequencythe 4-digit OTP (demo 3)
CWE-640Weak password recovery mechanismthe security question (demo 4)
CWE-841Improper enforcement of behavioral workflowskipping payment (demo 5)
CWE-657, CWE-501Violation of secure design principles; trust boundary violationthe general pattern behind all five

Reading the canvas: the client (alice, the customer, and mallory, the attacker) is on the left with the full request, the response and mallory's notes. The server side is on the right: the app with its two-line handler, the database tables the handler touches, the design rules, and the server log. Boxes fly for every hop: blue is an HTTP request, orange an SQL statement, grey the rows that come back (or an email), green a 2xx reply and red a 4xx reply. The database is reset to the attack's starting rows at the start of each run.

Trusting the client: the price in the request

The checkout page has the price in a hidden field, and the server was designed to use it: total = body.price * body.qty. mallory edits the JSON to "price":1 and gets order 1001 for $1 (Demo 1: client-sent price). Everything in a request, hidden fields, cookies, JSON and headers included, is chosen by whoever sends it. The fixed design looks the price up: SELECT price FROM products WHERE id='tv', so the same request gives a total of $500 and price is simply ignored. The client may show rules (disable a button, hide a field); only the server can enforce them.

Race conditions: check, then act

Gift card GC-7 holds $50. The redeem handler first checks SELECT balance, then writes. mallory sends the same request twice at the same moment (Demo 2: gift-card race). On the clock: at t1 A reads 50, at t2 B reads 50, at t3 A writes, at t4 B writes on the strength of a check that is no longer true. Credit: $100 from a $50 card. This is a time-of-check to time-of-use gap, and it exists whenever a decision and the write that depends on it are separate steps.

Two time-down timelines for requests A and B. Left, check then act: A and B both read balance 50 before either writes, both write, credit 100. Right, one atomic UPDATE: A updates 1 row and locks it, B waits, A commits, B re-checks balance 0 and gets 0 rows, 409, credit 50
Two separate steps let requests interleave; one atomic statement leaves no gap.

The fixed design makes the check part of the write: UPDATE giftcards SET balance = balance - 50 WHERE code = 'GC-7' AND balance >= 50. The database locks the row for A, B waits, and after A commits B's WHERE finds balance 0: 0 rows, 409 Conflict, credit $50. Other ways to the same end: SELECT … FOR UPDATE, a unique constraint (one redemption row per card), the SERIALIZABLE isolation level, or an idempotency key per request.

No limit on guesses: the 4-digit OTP

A one-time code of 4 digits has only 10,000 values. mallory knows alice's password from a leak; the shop texts alice a code, 4821. With no try counter and no expiry, a script tries 0000, 0001, … and logs in as alice on guess 4,822 (Demo 3). In the fixed design each code allows 5 tries and expires after 5 minutes: guesses 0000 to 0004 get 401, the sixth gets 429 Too Many Requests and the code is deleted. Only the first five guesses are checked, so her chance was 5 in 10,000. A per-IP rate limit alone does not do this: the attacker has many IPs, so the limit has to be on the thing being guessed (this code, this account).

Recovery that anyone can answer

Security questions ("name of your first pet?") are a weak design: answers are often public, guessable or shared (Demo 4). alice's answer, Biscuit, is in a public post, so mallory resets the password and the account is hers. The fixed design sends a long random single-use token to the account's email, valid for 15 minutes; the response is the same whether the account exists or not. mallory's guess at /reset/confirm gets 400 and alice's password is unchanged. NIST SP 800-63B says not to use knowledge-based questions at all.

Skipping a step in a workflow

Checkout is /cart/add → /checkout/pay → /checkout/confirm. The UI shows Confirm only after paying, and the confirm handler was designed on that assumption. mallory calls /checkout/confirm straight after adding to the cart, and order 1002 is confirmed with no payment (Demo 5). The fixed design keeps a state machine on the server and only allows its edges: UPDATE orders SET state='confirmed' WHERE id=1002 AND state='paid' matches 0 rows, so the answer is 409 Conflict and the order stays in cart.

Checkout state machine: cart to paid to confirmed to shipped are the allowed transitions; the jump from cart directly to confirmed is drawn in red and rejected with 409 Conflict
The server allows only the drawn edges; the cart → confirmed jump gets 409.

How to prevent it

  • Threat model each feature before writing it: for each step of the flow, ask "what can go wrong, and who would want it to?" (STRIDE, or simply abuse cases).
  • Write abuse cases next to the use cases ("redeem the same card twice", "confirm without paying") and turn them into tests.
  • Keep business rules on the server: prices, discounts, limits, roles and ownership come from the server's data, never from the request.
  • Limit everything that can be guessed or repeated: tries per code, per account and per IP; expiry for codes and tokens; quotas on expensive operations.
  • Make check-and-change atomic (conditional UPDATE, row locks, unique constraints) and requests idempotent where money or stock is involved.
  • Model multi-step flows as a server-side state machine; reject every transition that is not an edge.
  • Use secure design patterns and vetted components for authentication, recovery and sessions instead of designing your own.
  • Use a secure development lifecycle (OWASP SAMM, the Application Security Verification Standard) so design reviews happen before code exists.

OWASP Top 10:2025 series

See also How HTTP Cookies Work (sessions and CSRF), How OAuth 2.0 Works (delegated login) and MVCC and isolation levels (why two transactions can both read the old balance).

See also Server-Side Request Forgery: a link-preview feature that lets the user choose the server's network destination, and why only an allowlist or a resolve-once IP check fixes it.

What the page leaves out

  • Real threat-modelling methods (STRIDE, data-flow diagrams, attack trees) and the design review process itself.
  • Payment providers, carts with many items, coupons and refunds: each demo has one item and one rule.
  • Real timing: the race is drawn on a clock of steps, and the 4,820 OTP guesses from 0001 to 4820 are one line. Real OTPs are often 6 digits, and limits are also kept per account and per IP.
  • Hashing of reset tokens and passwords (shown as q7Zx…, hash#1), email security, and account lockout's denial-of-service trade-off.
  • Other A06 design flaws: unlimited resource use (no quotas), missing segregation of tenants, trusting data across a trust boundary, and bot or scalping abuse.