A04 Insecure Design
A04:2021 — Insecure Design — covers flaws in the system design itself, not specific bugs. Missing threat modelling, no rate limits, no abuse cases, secret data in URLs — bugs you can’t patch with a code fix.
Threat model + secure design patterns
EXAMPLE
# A04 is the category bugs that aren't in the code — they're in the architecture.
# Code fixes don't help; you have to redesign.
# === 1. Missing threat modelling ===
#
# Symptom: features ship without anyone asking 'what could go wrong?'
# Fix: run a 30-60 min threat model session before non-trivial features.
# Cover STRIDE:
# S - Spoofing
# T - Tampering
# R - Repudiation
# I - Information disclosure
# D - Denial of service
# E - Elevation of privilege
# Output: a backlog of mitigations with owners.
# === 2. No rate limiting ===
#
# Symptom: login + signup + password-reset endpoints take unlimited requests.
# Effect: credential stuffing, brute-force, mass spam.
# Fix:
# - Per-IP rate limit on the edge (Cloudflare, NGINX, app-level)
# - Per-account limit (3 failed logins → 5 min lockout, 10 → 30 min)
# - CAPTCHA / Turnstile after threshold
# - Bot detection for automated traffic
#
# Express + express-rate-limit
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis();
const limiter = rateLimit({
windowMs: 60_000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
});
app.use('/api', limiter);
const authLimiter = rateLimit({ windowMs: 60_000, limit: 5, keyGenerator: (req) => req.body.email });
app.post('/auth/login', authLimiter, handleLogin);
app.post('/auth/password-reset', authLimiter, handleReset);
# === 3. Sensitive data in URLs ===
#
# Symptom: GET /api/orders/?ssn=123456789, share URLs that include tokens, password reset URLs in query strings.
# Effect: URLs land in browser history, server logs, third-party analytics, Referer headers.
# Fix:
# - Move sensitive data to POST body or Authorization header
# - Use opaque IDs (UUID) for resources, never database row IDs
# - Password reset tokens — single-use, short TTL, stored hashed on server
# - Strip Referer with rel="noreferrer"
# === 4. Predictable identifiers ===
#
# Symptom: /orders/100, /orders/101 — auto-incrementing IDs
# Effect: IDOR — change the ID, see another user's data
# Fix:
# - UUID / KSUID for resources, opaque to attackers
# - But ALSO: server-side authz check on every read/write — never rely on opaque IDs alone
await db.orders.findOne({ id: orderId, userId: req.user.id }); // scoped
# === 5. Default credentials / weak passwords ===
#
# Symptom: admin/admin works on first install; no password complexity check
# Effect: every CVE list ever.
# Fix:
# - Force password change at first login
# - Check against HaveIBeenPwned at signup
# - argon2id with OWASP 2024 params
# - Optional MFA on privileged accounts
# === 6. Account enumeration ===
#
# Symptom: /login returns 'invalid password' vs 'user not found'; signup returns 'email already exists'
# Effect: attackers harvest valid emails for phishing
# Fix:
# - Generic responses: 'Invalid email or password'
# - Signup: 'If this email is new, we've sent a verification link'
# - Same response time for valid vs invalid (constant-time)
# - Rate-limit so probes are slow
# === 7. Business-logic flaws ===
#
# Symptom: feature works as designed but the design allows abuse
# Examples:
# - Refunds: negative quantity → free items
# - Voucher stacking: combine two 100%-off codes → infinite discount
# - Race conditions: simultaneous balance check + transfer → double-spend
# Fix:
# - Server-side validation of ALL business rules (never trust client)
# - Idempotency keys on payment / mutation endpoints
# - Database-level constraints (CHECK total >= 0)
# - Optimistic locking for concurrent edits
# - Pen test against agreed abuse cases
# === 8. No audit trail ===
#
# Symptom: who deleted that order? No idea.
# Fix:
# - Append-only audit log for sensitive actions (delete, role change, payment, login)
# - Include: actor, action, target, before / after state, IP, UA, request ID
# - Forward to a SIEM that's tamper-resistant (separate from app DB)
await audit.log({
actor: req.user.id,
action: 'order.cancel',
target: orderId,
metadata: { reason },
ip: req.ip,
ua: req.headers['user-agent'],
request_id: req.id,
at: new Date(),
});
# === 9. No segregation of environments ===
#
# Symptom: dev / staging / prod share the same DB; prod-like test data on staging
# Fix:
# - Separate DBs, separate creds, separate cloud accounts where possible
# - Synthetic data for non-prod
# - Production data masking if you need real-shaped data
# === 10. Insecure defaults ===
#
# Symptom: feature flags default ON; new accounts have admin role; OAuth redirect URL is *
# Fix:
# - Deny by default for new permissions / features
# - Conservative new-account defaults; opt in to more access
# - Explicit redirect URI allowlists
# === 11. No abuse-case testing ===
#
# Symptom: tests cover happy path; QA never asks 'what if I cheat?'
# Fix:
# - Pen test before major launches
# - Bug bounty — bound + rewarded
# - Add abuse cases to the test plan (negative quantity, replay, race, role change attempts)
# - Pre-prod scanning (OWASP ZAP, Nuclei, semgrep + custom rules)
# === 12. Security debt / no patching cadence ===
#
# Symptom: dependencies + base images last updated 2 years ago
# Fix:
# - Renovate / Dependabot weekly
# - Trivy / Snyk in CI for image + dep scanning
# - Defined patch SLA: critical < 7 days, high < 30 days
# - Asset inventory: what's running, what version, what owner
# === 13. Secret management ===
#
# Symptom: secrets in env vars, in .env files, in source
# Fix:
# - KMS / Secrets Manager / Vault — fetch at startup or on demand
# - Rotation schedule + automated rotation where possible
# - Pre-commit gitleaks / trufflehog
# === 14. Logging that creates new risks ===
#
# Symptom: full PII / passwords / card data in logs
# Fix:
# - Structured logging with allowed fields only
# - Redact known sensitive patterns (cards, SSN, tokens)
# - Restricted access to logs (role-based)
# === 15. Failure to design for rollback ===
#
# Symptom: a bad deploy can't be reversed without data loss
# Fix:
# - Blue/green or canary deploys
# - Database migrations split from deploys (deploy backward-compat, migrate, then clean up)
# - Feature flags so you can disable bad features without redeploy
# === Mitigation patterns ===
# 1. Defence-in-depth — assume each layer can fail
# 2. Deny by default — explicit allow > implicit reject
# 3. Least privilege — every service / role gets minimum permissions
# 4. Fail safe — errors don't expose information; UI shows generic messages
# 5. Validate at every trust boundary — UI, API, service, DB
# 6. Use established libraries — battle-tested > rolled-your-own
# 7. Plan for compromise — incident response runbook, breach notification flow
# === Resources ===
# - OWASP ASVS — 'Application Security Verification Standard' — checklist of designs to enforce
# - OWASP SAMM — 'Software Assurance Maturity Model' — process improvement
# - NIST SSDF — Secure Software Development Framework
# - Microsoft SDL — Security Development Lifecycle
Why it matters
Insecure Design is the category you can’t fix with a code patch — you need a different architecture. Bake threat modelling, rate limits, abuse cases, and audit logs into the design phase; retrofit costs 10x at minimum.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// A04 Insecure Design — broken at the threat-model layer. // Example: password reset that ALSO logs you in without verifying ownership. // Fix: threat-model new features, write abuse-cases, add invariants.Try it Yourself »
Discussion
Loading…