GUIDE · OWASP Top 10:2025

A06:2025 Insecure Design

The design itself permits abuse — no amount of correct implementation fixes a missing control. (Implementation bug = "you coded it wrong"; design flaw = "you never planned for it".)

lesson4 sectionsruns in browser2021: A04 same name15 asserts139 lines

At a glance

WHAT
The design itself permits abuse — no amount of correct implementation fixes a missing control. (Implementation bug = "you coded it wrong"; design flaw = "you never planned for it".)
HAPPENS
no rate limit on password reset / OTP / login (design didn't call for one); business logic flaws: negative quantity -> refund, coupon reused N times, TOCTOU race on balance (double spend), skipping a step in a multi-step flow; trusting client-side validation; no abuse cases considered ("what if a bot does this 10k times?"); unlimited free tier; security questions as 2FA.
SPOT
requirements with no "abuse" / "misuse" cases; no threat model for new features; invariants enforced only in the UI; read-then- write without a lock/transaction; monetary ops without idempotency keys; features that are "too helpful" (account enumeration).
FIX
threat modeling (STRIDE: Spoofing, Tampering, Repudiation, Info disclosure, DoS, Elevation of privilege); abuse cases in stories; secure design patterns & reference architectures; invariants enforced server-side in one place; rate limits / quotas / token bucket; trust boundaries drawn explicitly; least privilege; defense in depth; secure defaults; segregation of tenants.
SAY
CWE-840 Business Logic Errors, CWE-799 Improper Control of Interaction Frequency, CWE-367 TOCTOU, CWE-20 Improper Input Validation (as design). Shift-left, threat model, attack tree, abuse case, trust boundary, invariant. Pitch: "Ask 'how would I abuse this?' before writing it; encode the answer as invariants the server enforces."

The idea#

Picture a board game that never says you have to move forward. The printing is sharp. The dice are fair. A player slides backward onto the bank space, collects, and does it until the box is empty. You followed the dice. The designer never wrote the rule that would have stopped it.

That is Insecure Design. The design itself permits the abuse. Implementing each step faithfully still does the wrong thing, because the wrong thing was allowed. An implementation bug means the rule was coded wrong. A design flaw means the rule was never made. The real control is an invariant the server enforces in one place. Quantity is a whole number from 1 to 99. The price comes from your catalog. A coupon is single-use. The total stays at zero or above. A balance change runs inside a lock, or as one conditional update, so two requests cannot both spend the same money. A short code such as a one-time password sits behind a token bucket: a few tries, then a slow refill. Before you build the feature you ask how it gets abused, and you write the answer as STRIDE: spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege.

Failure looks like the missing rule. A negative quantity turns a charge into a credit. A coupon applies on every order, and the total drops below zero. Two requests both read a balance of 100 and both write 0, so twice the money leaves. A six-digit code can be tried all afternoon because the design never counted attempts. The checks live in the page, and a client that skips the form skips the checks.

Why it matters#

Careful code inside a loose rule still ships the loose rule. A review that hunts for typos will approve it. On the 2025 OWASP list this is Insecure Design, and you settle it in the ticket, while the behavior is still a sentence.

The harm shows up in money, inventory, and accounts that do what the code says. Races appear when the feature gets popular. Unlimited tries appear when a script finds the route. Checks that live only in the browser disappear the moment a client skips the form.

Write the abuse case beside the story. The browser may send a sku and a quantity. Your server owns the price. Enforce that in the function that charges. Serialize the balance update per account. Put a token bucket on any route a script would hammer, and say the six STRIDE names while you design.

A small example#

JS
function lineTotal(unitPriceCents, qty) {  if (!Number.isInteger(qty) || qty < 1 || qty > 99) {    throw new Error('invalid qty');  }  if (!Number.isInteger(unitPriceCents) || unitPriceCents < 0) {    throw new Error('invalid price');  }  return unitPriceCents * qty;} const total = lineTotal(2500, 2);

total is 5000. Quantity has to be a whole number from 1 to 99. The price is cents your server chose, and a negative price throws before any math.

What the code below is doing#

Four blocks. Three are rules a server can enforce. One is the vocabulary you use while designing.

The order flow is checkout. The cart is a non-empty array of at most 50 lines. Quantity is an integer from 1 to 99. The sku has to exist in products, and the cents come from that map. A coupon has to exist, stay under maxUses, and land in usedBy for that user. Math.max keeps the total at zero or above. A second use throws. The asserts cover a negative quantity, a fractional quantity, an unknown sku, a total clamped at zero, and a coupon the next caller finds exhausted.

The balance race is withdraw inside withLock. One withdrawal occupies the critical section for that user at a time. After a pause that stands in for a round trip, the code subtracts only when the balance still covers the amount. Three withdrawals of 100 against a balance of 100 produce one success and a final balance of 0. A non-positive amount returns false. The comment points at idempotency keys so a retry does not charge twice.

The limit is TokenBucket. otpLimiter stores five tokens and refills one per minute. Five takes succeed, the sixth fails, another key still succeeds, and the first key succeeds again after a minute. The comment says to bind the code to the session and expire it in a few minutes.

STRIDE is a frozen map of six threats to the property you are protecting: authentication, integrity, audit logging, confidentiality, availability, and authorization. An assert checks that all six names are present.

When you talk about it, say this: ask how you would abuse the feature, then write that answer as a rule the server enforces.

Everything below is the demonstration. Same idea, written as code you can run.

JSowasp/a06-insecure-design.js · setup
'use strict';const assert = require('node:assert/strict');
01

Business logic flaw: order/coupon flow#

JS
// VULNERABLE://   function checkout(cart, coupon) {//     let total = cart.reduce((s, i) => s + i.price * i.qty, 0);   // qty = -5 => negative total => refund//     if (coupon) total -= coupon.amount;                           // coupon reused forever, total < 0//     charge(user, total);//   }// FIXED: invariants enforced at the boundary; coupon is single-use via atomic state.const products = new Map([['sku1', { price: 2500 }], ['sku2', { price: 999 }]]);   // server prices, in centsconst coupons = new Map([['SAVE10', { amount: 1000, usedBy: new Set(), maxUses: 1 }]]);function checkout(userId, cart, couponCode) {  if (!Array.isArray(cart) || cart.length === 0 || cart.length > 50) throw new Error('invalid cart');  let total = 0;  for (const { sku, qty } of cart) {    if (!Number.isInteger(qty) || qty < 1 || qty > 99) throw new Error('invalid qty');    // invariant: 1..99    const p = products.get(sku);    if (!p) throw new Error('unknown sku');                                                // server-side price    total += p.price * qty;  }  if (couponCode !== undefined) {    const c = coupons.get(couponCode);    if (!c) throw new Error('invalid coupon');    if (c.usedBy.has(userId) || c.usedBy.size >= c.maxUses) throw new Error('coupon exhausted');    c.usedBy.add(userId);                                                                  // consume atomically with order    total = Math.max(0, total - c.amount);                                                 // invariant: total >= 0  }  return { userId, total };}
Verified by 7 asserts
JS
assert.equal(checkout('u1', [{ sku: 'sku1', qty: 2 }]).total, 5000);assert.throws(() => checkout('u1', [{ sku: 'sku1', qty: -5 }]), /invalid qty/);assert.throws(() => checkout('u1', [{ sku: 'sku1', qty: 1.5 }]), /invalid qty/);assert.throws(() => checkout('u1', [{ sku: 'sku1', qty: 1, price: 1 }, { sku: 'nope', qty: 1 }]), /unknown sku/);assert.equal(checkout('u2', [{ sku: 'sku2', qty: 1 }], 'SAVE10').total, 0);              // 999 - 1000 clamps to 0assert.throws(() => checkout('u2', [{ sku: 'sku2', qty: 1 }], 'SAVE10'), /coupon exhausted/);assert.throws(() => checkout('u3', [{ sku: 'sku2', qty: 1 }], 'SAVE10'), /coupon exhausted/);   // maxUses hit
02

Race condition / TOCTOU on balance (double spend)#

JS
// VULNERABLE://   const bal = await db.get(user);          // two concurrent requests both read 100//   if (bal >= amount) await db.set(user, bal - amount);   // both pass, both write 0 => spent 200// FIXED: single atomic conditional update (UPDATE ... SET bal = bal - $1 WHERE id = $2 AND bal >= $1),// or a per-key lock/queue. Simulated here with a serialized critical section.const balances = new Map([['u1', 100]]);const locks = new Map();async function withLock(key, fn) {  const prev = locks.get(key) || Promise.resolve();  let release;  const next = new Promise((r) => { release = r; });  locks.set(key, prev.then(() => next));  await prev;  try { return await fn(); } finally { release(); }}async function withdraw(user, amount) {  if (!Number.isInteger(amount) || amount <= 0) return false;  return withLock(user, async () => {    const bal = balances.get(user);    await Promise.resolve();                      // simulate async gap where the race would happen    if (bal < amount) return false;    balances.set(user, bal - amount);    return true;  });}// Also: idempotency keys on money-moving endpoints so retries don't double-charge.
03

Rate limiting: token bucket for OTP / password-reset attempts#

JS
// VULNERABLE: POST /verify-otp accepts unlimited guesses; 6 digits = 1e6 tries, trivial for a bot.// FIXED:class TokenBucket {  constructor(capacity, refillPerMs) { this.capacity = capacity; this.refill = refillPerMs; this.buckets = new Map(); }  take(key, now = Date.now()) {    const b = this.buckets.get(key) || { tokens: this.capacity, at: now };    b.tokens = Math.min(this.capacity, b.tokens + (now - b.at) * this.refill);    b.at = now;    if (b.tokens < 1) { this.buckets.set(key, b); return false; }    b.tokens -= 1;    this.buckets.set(key, b);    return true;  }}const otpLimiter = new TokenBucket(5, 1 / 60_000);   // 5 attempts, +1 per minutelet t = 1_000_000;for (let i = 0; i < 5; i++) assert.equal(otpLimiter.take('u1', t), true);// Also: invalidate OTP after N failures, bind OTP to session, expire in 5-10 minutes.
Verified by 4 asserts
JS
assert.equal(otpLimiter.take('u1', t), false);                 // 6th attempt blockedassert.equal(otpLimiter.take('u2', t), true);                  // other key unaffectedassert.equal(otpLimiter.take('u1', t + 59_000), false);        // not yet refilledassert.equal(otpLimiter.take('u1', t + 60_000), true);         // one token back after a minute
04

STRIDE in one table (say these names)#

JS
const STRIDE = Object.freeze({  Spoofing: 'authentication',  Tampering: 'integrity',  Repudiation: 'non-repudiation / audit logging',  InformationDisclosure: 'confidentiality',  DenialOfService: 'availability',  ElevationOfPrivilege: 'authorization',}); (async () => {  const results = await Promise.all([withdraw('u1', 100), withdraw('u1', 100), withdraw('u1', 100)]);  console.log('ok: owasp/a06-insecure-design.js');})();
Verified by 4 asserts
JS
assert.equal(Object.keys(STRIDE).length, 6);  assert.deepEqual(results.filter(Boolean).length, 1);         // exactly one wins  assert.equal(balances.get('u1'), 0);                         // never negative  assert.equal(await withdraw('u1', -50), false);

See also