OWASP Top 10 for Vibe Coders, Part 8: Insecure Design (A06:2025)

No typo. No missing check. The code does exactly what it was built to do — and that’s the whole problem.

Mindmap of this article: a feature working exactly as designed, the negative-quantity checkout example, threat modelling questions, the one-line validation fix, and why clean code alone doesn't catch this

Insecure Design is A06:2025 in the OWASP Top 10; OWASP’s own page for it goes into more depth if you want to read further.

One of my favourite pieces of insecure design was the wristband you got at Wicksteed Park in Kettering. Growing up nearby, we worked out that if you kept the band on and hid it under your sleeve all week, one entry payment would get you in twice. Nothing was broken: the band did exactly what it was designed to do, which was show that you’d paid, and nobody had asked how long it would stay on a wrist. I like to think we were early contributors to the park’s security review. That’s this category in a wristband, and the Sticker Shop has its own version of it below.

If you know how customers really use a feature, including the ways nobody planned for, this one is for you, because that knowledge is most of the fix.

Every category in this series so far has had a clear “fix the line of code” answer. Insecure Design is different, and I’d slow down for it, because it’s the category that catches careful coders off guard. There’s no bug to find, in the normal sense. The code runs correctly, does exactly what it was told to do, every single time — and the plan it’s following has a hole in it that nobody noticed, because nobody asked “what’s the worst thing someone could do with this feature, on purpose?”

This is sometimes called a business logic flaw, and it’s a good name for it: the logic of the business process — not the logic of the code — is where the gap lives.

A feature working perfectly, and still a disaster

Here’s a very realistic example for Sticker Shop. The checkout code lets a customer set the quantity of each item, multiplies it by the price, and totals the order:

@app.route("/checkout", methods=["POST"])
def checkout():
    product_id = request.form["product_id"]
    quantity = int(request.form["quantity"])
    product = Product.query.get(product_id)

    total = product.price * quantity
    order = Order(product_id=product_id, quantity=quantity, total=total)
    db.session.add(order)
    db.session.commit()

    account.balance -= total  # charge the customer's stored balance
    return {"order_id": order.id, "total": total}

Read it line by line. Nothing here is a mistake. int(request.form["quantity"]) correctly converts the input to a number. The multiplication is correct. The balance deduction is correct. Every individual line does its job perfectly.

Nobody, at any point while designing this feature, asked: “what happens if quantity is negative?”

Flow diagram: a customer submits a quantity, the price is multiplied by that quantity to get a total, the total is subtracted from the account balance, and an order is created — with no step anywhere that checks whether the quantity is a sensible, positive number before any of this happens

import requests

BASE = "http://localhost:5000"
session = requests.Session()
session.post(f"{BASE}/login", data={"email": "me@example.com", "password": "..."})

# A sticker costs £2.00. Order -100 of them.
response = session.post(f"{BASE}/checkout", data={
    "product_id": 7,
    "quantity": -100,
})

print(response.json())
# total = 2.00 * -100 = -200.00
# account.balance -= (-200.00)  ->  balance INCREASES by £200

2.00 * -100 is -200.00, which is correct arithmetic. Then account.balance -= total becomes account.balance -= (-200.00), which is the same as adding £200 to the attacker’s balance. The checkout “worked.” An order record even got created, for minus one hundred stickers. Nothing here is a coding error — int() parsed the input fine, the multiplication is fine, the subtraction is fine. The design simply never considered that a customer would ever type anything other than a sensible positive number, because nobody designing the feature was thinking like someone trying to break it.

This is exactly why Insecure Design sits as its own OWASP category, separate from Injection or Broken Access Control: those are about code failing to defend against an attack. This is about a feature whose designer never imagined there was anything to defend against in the first place.

Why “write cleaner code” doesn’t fix this

This is the point to stay with. You could refactor that checkout function endlessly, make the variable names beautiful, split it into five well-tested smaller functions, tidy up every last bit of formatting — and the negative-quantity hole stays exactly where it was, because tidiness was never the problem. The problem is a missing rule: quantities must be positive, full stop, and nothing in the design ever said so.

Fixing Insecure Design means asking different questions at a different stage — before writing the code, not after:

That last question is the core of threat modelling, and you don’t need a certification to do a basic version of it. It’s simply sitting down with a feature before you build it and asking, out loud, “how would I abuse this if I wanted to?”

Why this category is so easy to miss

Insecure Design bugs are unusually dangerous for a specific reason: they survive code review, survive testing, and survive even a careful read-through by someone experienced, as long as that someone is only checking “does this do what it’s supposed to do?” The negative-quantity checkout bug would pass every normal test you’d think to write — order 1 sticker, order 5 stickers, order 0 stickers and get a sensible error. Nobody orders -100 of something by accident, so no ordinary test case would ever stumble onto it. It only surfaces when someone deliberately asks the adversarial question instead of the functional one, which is exactly why threat modelling has to be a separate, explicit step — not something you hope falls out of writing careful code or running your usual test suite.

The fix, once the gap is spotted

Once you’ve named the missing rule, enforcing it is usually simple:

quantity = int(request.form["quantity"])
if not (1 <= quantity <= 50):
    abort(400, "Quantity must be between 1 and 50.")

That one extra check is the whole fix. The 400 is the status code the server sends back to say “I’m not doing this — the request itself doesn’t make sense,” as distinct from a 200 for success or a 500 for “something broke on my end.” Sending it back, with a plain-English reason, is what turns a silent, exploitable gap into an ordinary, visible error message.

Flow diagram: the same checkout process as before, but now with one added decision point right after the quantity is read — if the quantity is outside 1 to 50, the request is rejected immediately with an error; only a valid quantity is allowed to continue through to the price calculation and balance update

One check, once you knew to write it. That’s the pattern with almost every insecure design bug: trivial to fix, once someone has actually asked the “what’s the worst someone could do here, on purpose?” question. So the useful habit isn’t learning more fixes, it’s building that question into the point where you design a feature, before there’s any code to fix. The examples and prompts below are there to make that question a reflex.

A few other classic examples worth recognising the shape of, because they all follow the same pattern as the negative-quantity bug: a discount code with no limit on how many times it can be applied to the same order; a password-reset flow that emails the new password in plain text instead of a one-time link; a “forgot password” security question whose answer (mother’s maiden name, first pet) is public information on someone’s social media. None of these are typos. All of them are a feature behaving exactly as built, against a plan that never considered how it could be turned against its own users.

Why this fix exists

For a long time, security was something you tested for at the end, once the thing was built. The trouble with design flaws is that there’s no faulty line for a test to find, so they sailed straight through. Threat modelling grew up to catch them earlier: Microsoft’s STRIDE, described by Loren Kohnfelder and Praerit Garg in 1999, gave teams a checklist of ways a design could be abused, to run through before writing any code. OWASP added Insecure Design to the Top 10 as a new category in 2021, mainly to make the point that some problems have to be prevented on the whiteboard because there’s nothing to patch afterwards. The range check in this article is tiny, but it only exists because somebody asked the question at design time.

My take

The view running through this whole series is that you inspect for what’s already wrong before you build anything for what might someday be needed: check the hull for holes before anyone fits a dance floor. This is the post that might look like it argues against that, because it asks you to imagine an attacker who hasn’t turned up yet. I don’t think it does. “What’s the worst someone could do with this, on purpose?” is an inspection of something you’ve already built, or are about to, the same way you’d look at a new fitting and ask what happens when it’s loaded up in a gale. That’s a different thing from building for needs that might arrive someday. So I’d ask the question of every feature that touches money, stock or someone’s account, and I’d skip formal threat-modelling workshops, risk registers and design-review boards until enough people are involved that the question stops getting asked naturally. On a one-person boat the design review is the skipper asking that question out loud, before each new feature rather than after the first incident.

Check your own code

  1. “Go through every form field and API parameter in this app that accepts a number - quantities, amounts, discount percentages. For each one, tell me whether the code enforces sensible minimum and maximum values, or whether a negative number, zero, or an enormous number would be accepted.”
  2. “For each major feature in this app (checkout, discount codes, account balance, refunds), describe the ‘happy path’ it was designed for, then suggest one realistic way a determined user could deliberately misuse it to get an outcome the designer didn’t intend.”
  3. “Look at this app’s checkout and payment logic specifically. Is there anywhere a user-supplied value flows directly into a calculation that changes money owed or a stored balance, without a sanity check on that value first?”

Tested (Python 3.12.8 and Flask 3.1.3): with no validation, ordering -100 stickers took a £100 balance to £300. With the range check, the balance stayed at £100 and the server answered 400. That 400 is an HTML error page rather than JSON, so the attack script’s final response.json() call fails on the fixed app, which is exactly what you want to see. Code: gist.

Next in the series: Software Supply Chain Failures — what happens when the danger isn’t your code at all, but the code you imported without a second thought.

This post is part of the OWASP Top 10 for Vibe Coders series; the first post introduces the series and what’s in it.