Back

A04:2021 – Insecure Design - Examples

Each example below contrasts a vulnerable design with a secure design. The point is not to fix a typo — the vulnerable code often works perfectly and passes its functional tests. The flaw is in what the design assumes and omits. Watch for the missing control in each pair.

Example 1: Trusting Client-Supplied Price (Python / Flask)

The design flaw: The client tells the server what the item costs. This is an architectural mistake — price is authoritative data that must live on the server.

Vulnerable Design

@app.route('/checkout', methods=['POST'])
def checkout():
    data = request.get_json()
    # DESIGN FLAW: price and total come straight from the client.
    total = sum(item['price'] * item['qty'] for item in data['items'])
    charge_card(data['card_token'], total)
    return jsonify({'charged': total})

# Attacker simply POSTs {"items":[{"sku":"LAPTOP","price":1,"qty":1}], ...}

Secure Design

@app.route('/checkout', methods=['POST'])
def checkout():
    data = request.get_json()
    total = Decimal('0')
    for item in data['items']:
        # Price is looked up server-side from the trusted catalog.
        product = Catalog.get_or_404(item['sku'])
        qty = int(item['qty'])
        if qty < 1 or qty > product.max_per_order:
            abort(400, 'invalid quantity')
        total += product.price * qty      # server computes the total

    if total <= 0:
        abort(400, 'invalid total')
    charge_card(data['card_token'], total)
    return jsonify({'charged': str(total)})

Principle: The client may say what it wants (SKU, quantity), never what it costs. Security-relevant values are derived server-side.

Example 2: Skippable Checkout Workflow (Node.js / Express)

The design flaw: Each step is an independent endpoint that trusts the client to have completed the previous ones. There is no authoritative order state, so the confirm step can be called directly without paying.

Vulnerable Design

// Three independent endpoints, no server-side state machine.
app.post('/checkout/payment', (req, res) => { chargeCard(req.body); res.sendStatus(200); });

app.post('/checkout/confirm', async (req, res) => {
  // DESIGN FLAW: assumes payment already happened. Never verifies it.
  const order = await Orders.create({ items: req.body.items, status: 'CONFIRMED' });
  res.json({ orderId: order.id });          // free order if payment skipped
});

Secure Design

// Authoritative server-side state machine; transitions are validated.
const NEXT = { CREATED: 'SHIPPING_SET', SHIPPING_SET: 'PAID', PAID: 'CONFIRMED' };

app.post('/checkout/payment', async (req, res) => {
  const order = await Orders.get(req.body.orderId, req.user);
  if (order.status !== 'SHIPPING_SET') return res.status(409).json({ error: 'bad state' });
  await chargeCard(order.total, req.body.cardToken);   // server-known total
  await order.transitionTo('PAID');
  res.sendStatus(200);
});

app.post('/checkout/confirm', async (req, res) => {
  const order = await Orders.get(req.body.orderId, req.user);
  // Enforce the only legal predecessor state.
  if (order.status !== 'PAID') return res.status(409).json({ error: 'payment required' });
  await order.transitionTo('CONFIRMED');
  res.json({ orderId: order.id });
});

Principle: Model workflows as a server-side state machine. Every transition asserts its legal predecessor, so steps cannot be skipped or reordered.

Example 3: OTP With No Anti-Automation (Python)

The design flaw: A 6-digit code with unlimited attempts and unlimited re-requests. The math guarantees it will be brute-forced.

Vulnerable Design

@app.route('/verify-otp', methods=['POST'])
def verify_otp():
    data = request.get_json()
    expected = otp_store.get(data['user_id'])
    # DESIGN FLAW: no attempt cap, no lockout, no expiry check.
    if data['code'] == expected:
        return issue_session(data['user_id'])
    return jsonify({'error': 'invalid'}), 401

Secure Design

MAX_ATTEMPTS = 5

@app.route('/verify-otp', methods=['POST'])
def verify_otp():
    data = request.get_json()
    uid = data['user_id']
    rec = otp_store.get(uid)

    if rec is None or rec.expires_at < now():
        abort(400, 'code expired, request a new one')          # short TTL
    if rec.attempts >= MAX_ATTEMPTS:
        otp_store.invalidate(uid)                              # lock out
        abort(429, 'too many attempts; code invalidated')

    rec.attempts += 1
    otp_store.save(rec)
    # constant-time compare avoids timing leaks
    if hmac.compare_digest(str(data['code']), str(rec.code)):
        otp_store.invalidate(uid)                             # one-time use
        return issue_session(uid)
    return jsonify({'error': 'invalid'}), 401

Principle: A small secret space must be paired with strict rate limiting, a short TTL, an attempt cap, and single use — all designed in, not added later. (Rate limiting the endpoint per IP/user is an additional layer.)

Example 4: Check-Then-Act Race Condition (Java)

The design flaw: The balance is checked, then debited in a separate step. Concurrent requests all pass the check before any debit lands, allowing an overdraw.

Vulnerable Design

public void withdraw(long accountId, BigDecimal amount) {
    Account acct = repo.findById(accountId);
    // DESIGN FLAW: check-then-act is not atomic. 100 concurrent
    // requests all see the same balance and all "succeed".
    if (acct.getBalance().compareTo(amount) >= 0) {
        acct.setBalance(acct.getBalance().subtract(amount));
        repo.save(acct);
    } else {
        throw new InsufficientFundsException();
    }
}

Secure Design

@Transactional
public void withdraw(long accountId, BigDecimal amount) {
    if (amount.signum() <= 0) throw new ValidationException("amount must be > 0");

    // Atomic, conditional UPDATE: the database enforces the invariant.
    int rows = jdbc.update(
        "UPDATE account SET balance = balance - ? " +
        "WHERE id = ? AND balance >= ?",
        amount, accountId, amount);

    if (rows == 0) throw new InsufficientFundsException();  // lost the race, safely
}

Principle: Replace check-then-act with an atomic operation (a conditional UPDATE, a unique constraint, or row locking) so concurrency cannot break the invariant. Treat concurrency as an adversarial condition in the design.

Example 5: Single-Use Coupon Abuse (Node.js)

The design flaw: "Has this coupon been used?" is checked and then marked used in two steps, so parallel requests each see it as unused.

Vulnerable Design

async function applyCoupon(userId, code) {
  const coupon = await db.coupons.findOne({ code });
  // DESIGN FLAW: non-atomic check-then-mark; N parallel calls all pass.
  if (coupon.used) throw new Error('already used');
  await db.coupons.update({ code }, { used: true });
  return coupon.discount;
}

Secure Design

async function applyCoupon(userId, code) {
  // Atomic reserve: only ONE update can flip used:false -> used:true.
  const result = await db.coupons.findOneAndUpdate(
    { code, used: false },                     // condition is part of the write
    { $set: { used: true, usedBy: userId, usedAt: new Date() } },
    { returnDocument: 'after' }
  );
  if (!result.value) throw new Error('coupon invalid or already used');
  return result.value.discount;
}

Principle: Redemption of a limited resource must be a single atomic reservation with a uniqueness guarantee — never a separate check and mark.

Example 6: Weak Knowledge-Based Recovery (Python)

The design flaw: Account recovery is gated on a "security question" whose answer is public information. No clean code can make a non-secret secret.

Vulnerable Design

@app.route('/recover', methods=['POST'])
def recover():
    data = request.get_json()
    user = Users.get(data['email'])
    # DESIGN FLAW: recovery hinges on a guessable/public answer,
    # and is weaker than the password it bypasses.
    if user.security_answer.lower() == data['answer'].lower():
        return reset_password(user, data['new_password'])
    abort(401)

Secure Design

@app.route('/recover', methods=['POST'])
def recover():
    data = request.get_json()
    user = Users.get(data['email'])
    # Always respond identically to prevent account enumeration.
    if user:
        # Send a single-use, short-lived, high-entropy token to a
        # pre-verified channel (email/authenticator). Recovery is
        # designed to be at least as strong as primary auth.
        token = secrets.token_urlsafe(32)
        recovery_tokens.store(user.id, hash_token(token), ttl_minutes=15)
        send_to_verified_channel(user, token)
    return jsonify({'status': 'if the account exists, a reset link was sent'})

Principle: Recovery must rely on possession of a verified channel and a high-entropy, short-lived, single-use token — never on knowledge factors that are public or guessable — and must not leak whether an account exists.

Example 7: Broken Tenant Segregation (Java)

The design flaw: The endpoint trusts the tenant id from the URL and never checks it against the caller's identity, so any tenant can read any other tenant's data.

Vulnerable Design

@GetMapping("/orgs/{orgId}/invoices")
public List<Invoice> list(@PathVariable String orgId) {
    // DESIGN FLAW: orgId is trusted from the URL. No check that the
    // caller belongs to orgId -> cross-tenant data access.
    return invoiceRepo.findByOrgId(orgId);
}

Secure Design

@GetMapping("/orgs/{orgId}/invoices")
public List<Invoice> list(@PathVariable String orgId, Authentication auth) {
    String callerOrg = ((AppUser) auth.getPrincipal()).getOrgId();
    // Trust boundary: the caller may only ever act within their own org.
    if (!callerOrg.equals(orgId)) {
        throw new AccessDeniedException("cross-tenant access denied");
    }
    // Defense in depth: scope the query itself to the authenticated org.
    return invoiceRepo.findByOrgId(callerOrg);
}

Principle: Tenant segregation is a trust boundary enforced server-side from the authenticated identity, with the data query itself scoped to that identity as defense in depth. Never trust a tenant identifier supplied by the client.

Summary of Design Principles

Design flawSecure design principle
Client supplies price/role/limitDerive all security-relevant values server-side.
Workflow steps trust each otherAuthoritative server-side state machine; validate every transition.
Small secret, unlimited guessesRate limit, cap attempts, short TTL, single use.
Check-then-act on shared stateAtomic operations / conditional updates / unique constraints.
Non-atomic redemptionAtomic reservation with uniqueness guarantee.
Knowledge-based recoveryPossession of a verified channel + high-entropy token.
Trusting client-supplied tenant idEnforce segregation from the authenticated identity at a trust boundary.

In every pair, the vulnerable version is correct code for an incorrect design. The fix is not a patch — it is a different design that includes the missing control.

Next Steps