Back

A04:2021 – Insecure Design - Prevention

Defense Philosophy: Shift Left

Insecure Design cannot be fixed with a scanner or a WAF rule, because the vulnerability is the absence of a control, not the presence of a bug. The only durable defense is to build security into the design — to "shift left" so that threats are identified and controls are specified before code exists. OWASP's guidance for this category is unusually process-oriented: establish a secure development lifecycle, use threat modeling, write abuse cases, and reuse vetted secure design patterns.

The layers below move from process (how you decide what to build) to concrete technical guardrails (what the running system enforces). No single layer is sufficient; together they make secure design the path of least resistance.

Layer 1: A Secure Development Lifecycle (SDLC)

Security must be a repeatable part of how features are built, not a one-off review. A practical secure SDLC weaves security into each phase:

PhaseSecurity activity
RequirementsWrite security & privacy requirements and abuse/misuse cases alongside functional stories.
DesignThreat model the feature; choose vetted secure design patterns; define trust boundaries.
ImplementationUse hardened, paved-road libraries and guardrail frameworks; peer review against the threat model.
VerificationAutomated abuse-case tests, design review, and targeted testing of the modeled threats.
Release / operateMonitor business-logic metrics and abuse signals; feed incidents back into the model.

Engage security professionals (or a trained security champion on the team) to evaluate and design controls, including privacy-related ones. Availability of a mature library of secure design patterns is what makes this affordable at scale.

Layer 2: Threat Modeling (STRIDE)

Threat modeling is the single highest-leverage activity for preventing insecure design. The method: draw the system's data flows, mark trust boundaries, and for each element ask "what can go wrong?" A common mnemonic is STRIDE:

ThreatProperty violatedExample control to design in
SpoofingAuthenticityStrong authentication on every trust boundary, including internal calls.
TamperingIntegrityServer-side validation; never trust client-supplied prices/roles/limits.
RepudiationNon-repudiationTamper-evident audit logging of sensitive actions.
Information disclosureConfidentialityLeast-privilege data access; uniform responses to prevent enumeration.
Denial of serviceAvailabilityRate limits, resource caps, pagination bounds, query-cost limits.
Elevation of privilegeAuthorizationDeny-by-default authorization enforced at a central trust boundary.

A lightweight four-question version works well in a design meeting: What are we building? What can go wrong? What are we going to do about it? Did we do a good enough job? Record the answers next to the design so reviewers can check them.

Layer 3: Security Requirements & Abuse/Misuse Cases

For every user story, write the adversarial counterpart. If the story is "As a shopper I can apply a coupon," the abuse case is "As an attacker I apply the same single-use coupon 100 times in parallel." Turning abuse cases into explicit, testable requirements is what prevents the control from being forgotten.

Story:        A user transfers funds between accounts.
Requirements (security):
  R1  amount MUST be > 0 and <= source balance (server-enforced).
  R2  transfers MUST be atomic (no check-then-act race).
  R3  > $10,000/day MUST require step-up authentication.
  R4  MAX 20 transfers per account per minute.
  R5  every transfer MUST be written to an immutable audit log.
Abuse cases (must FAIL):
  A1  negative amount is rejected.
  A2  100 concurrent transfers cannot overdraw the balance.
  A3  transfer to another tenant's account is denied.

Layer 4: Secure Design Patterns & Reference Architecture

Do not reinvent security-critical workflows. Maintain a library of vetted, reusable patterns and a reference architecture that new features are expected to follow. Examples:

Layer 5: Secure-by-Design Guardrails

Make the secure way the easy (and only) way. A guardrail is a paved-road abstraction that developers use for a whole class of operation, so the control cannot be forgotten. Instead of hoping every developer remembers to check ownership, funnel all data access through a layer that enforces it.

# Guardrail: a repository that ALWAYS scopes to the current tenant.
class TenantScopedRepo:
    def __init__(self, session, tenant_id):
        self._session = session
        self._tenant_id = tenant_id          # bound once, from the auth context

    def get_order(self, order_id):
        # Every query is automatically constrained to the caller's tenant.
        return (self._session.query(Order)
                .filter_by(id=order_id, tenant_id=self._tenant_id)
                .one_or_none())

Because the tenant filter is baked into the repository, a developer physically cannot write a query that crosses the tenant boundary through this path. The guardrail turns a design rule into an enforced invariant.

Layer 6: Design-Level Rate Limiting & Resource Control

Anti-automation must be a deliberate design decision on every sensitive or expensive endpoint. Enforce it centrally so it cannot be forgotten per-route.

Example: token-bucket limiting at the edge (NGINX)

limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /login {
    limit_req zone=login burst=3 nodelay;   # 5/min, small burst
    proxy_pass http://app;
}

Example: application-layer limiter (Express + middleware)

const rateLimit = require('express-rate-limit');

const otpLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,     // 15 minutes
  max: 5,                        // 5 attempts per window per key
  keyGenerator: req => req.body.userId || req.ip,
  standardHeaders: true,
  handler: (req, res) => res.status(429).json({ error: 'Too many attempts' })
});

app.post('/verify-otp', otpLimiter, verifyOtpHandler);

Complement rate limits with: account lockout / step-up after N failures, CAPTCHA or proof-of-work for anonymous bursts, pagination caps, maximum query depth/cost, request-size limits, and per-tenant quotas. These are design controls against DoS and brute force alike.

Layer 7: Segregation of Trust & Tiers

Design boundaries so that compromise or abuse of one component, tenant, or user does not cascade:

Layer 8: Server-Side Plausibility & Business-Logic Checks

All security-relevant validation must happen server-side, expressed as invariants the domain must never violate. Client-side checks are for UX only.

# Java: authoritative, server-side domain invariants
public Order placeOrder(OrderRequest req, AuthContext ctx) {
    Product p = catalog.findById(req.getSku())        // price from server,
        .orElseThrow(() -> new NotFound());           // NOT from the client
    int qty = req.getQty();

    // Plausibility / domain invariants:
    if (qty < 1 || qty > p.getMaxPerOrder())
        throw new ValidationException("invalid quantity");
    if (qty > inventory.available(p))
        throw new ValidationException("insufficient stock");

    Money total = p.getPrice().multiply(qty);         // server computes total
    Coupon c = req.getCouponCode() == null ? null
        : coupons.reserveSingleUse(req.getCouponCode(), ctx.userId()); // atomic
    total = applyDiscount(total, c);

    if (total.isNegativeOrZero())                     // discounts can't invert
        throw new ValidationException("invalid total");

    return orders.createFor(ctx.tenantId(), p, qty, total);  // tenant-scoped
}

Notice how each abuse case from the attack-vectors page is closed by an explicit invariant: price is server-derived, quantity is bounded, the coupon is atomically reserved, the total cannot go negative, and the order is tenant-scoped.

Layer 9: Abuse-Case Tests in CI

Design controls rot without tests that assert abuse fails. Encode each abuse case as an automated test that runs on every change, so a future refactor cannot silently remove the control.

# pytest: abuse cases must FAIL (i.e., be rejected)
def test_negative_quantity_rejected(client, auth):
    r = client.post('/order', json={'sku': 'SKU1', 'qty': -3}, headers=auth)
    assert r.status_code == 400

def test_single_use_coupon_not_reusable_concurrently(client, auth):
    # fire 20 parallel applies; exactly one may succeed
    results = run_parallel(lambda: client.post('/coupon',
                           json={'code': 'SAVE50'}, headers=auth), n=20)
    assert sum(r.status_code == 200 for r in results) == 1

def test_cross_tenant_order_denied(client, auth_tenant_a):
    r = client.get('/orgs/other-tenant/orders', headers=auth_tenant_a)
    assert r.status_code in (403, 404)

Design-Review Checklist

Next Steps