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.
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:
| CWE | Name | On this page |
|---|---|---|
| CWE-602 | Client-Side Enforcement of Server-Side Security | the price in the request (demo 1) |
| CWE-840 | Business Logic Errors | demos 1 and 5 |
| CWE-362, CWE-367 | Race condition; time-of-check time-of-use | the gift card (demo 2) |
| CWE-307, CWE-799 | No limit on authentication attempts; improper control of interaction frequency | the 4-digit OTP (demo 3) |
| CWE-640 | Weak password recovery mechanism | the security question (demo 4) |
| CWE-841 | Improper enforcement of behavioral workflow | skipping payment (demo 5) |
| CWE-657, CWE-501 | Violation of secure design principles; trust boundary violation | the 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.
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.
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
- A01 Broken Access Control
- A02 Security Misconfiguration
- A03 Software Supply Chain Failures
- A04 Cryptographic Failures
- A05 Injection
- A06 Insecure Design (this page)
- A07 Authentication Failures
- A08 Software or Data Integrity Failures
- A09 Security Logging and Alerting Failures
- A10 Mishandling of Exceptional Conditions
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.