The OWASP Top 10 Is a Checklist, Not a Strategy

The OWASP Top 10 isn't a ranking of the most dangerous vulnerabilities — it's the ten classes of bugs that show up in real-world breach reports year after year. Treat it as the minimum bar: if your app has any of these, you don't get to talk about “advanced threat modeling” until you fix them.

🤔 Sound familiar?
  • Security review is a one-line ticket: “Make sure it's secure”
  • Your team thinks parameterized queries are an SQL injection myth
  • Auth tokens live in localStorage and nobody pushed back
  • Errors return full stack traces to the browser

The 2021 list (still the current edition) reorganized around root causes — not specific bugs. Knowing the categories changes how you structure code, not just how you patch it.

A01 — Broken Access Control

The #1 category for a reason. IDOR (Insecure Direct Object Reference), missing authorization checks on API endpoints, and trusting the client to enforce roles. Authorization is the most-skipped check because it requires server-side context that's easy to forget.

// ❌ Broken: trusts the user-supplied ID
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
  res.json(invoice); // returns ANY invoice, even ones owned by other tenants
});

// ✅ Fixed: scope every query to the current principal
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: { id: req.params.id, tenantId: req.user.tenantId },
  });
  if (!invoice) return res.sendStatus(404); // 404, not 403 — don't leak existence
  res.json(invoice);
});

A02 — Cryptographic Failures

Hardcoded keys, MD5/SHA1 for passwords, AES-ECB, missing TLS on internal services, sensitive data logged in plaintext. The rule is simple: never roll your own crypto, never reuse password hashes for tokens, and never trust transport-layer security to cover application-layer secrets.

// Password hashing — Argon2id is the 2026 default; bcrypt cost ≥12 is still acceptable
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2 });
const ok = await argon2.verify(hash, candidate);

// Symmetric encryption at rest — use authenticated encryption (AES-GCM, never ECB/CBC raw)
import { randomBytes, createCipheriv } from 'node:crypto';
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
// Store: iv || tag || ciphertext

A03 — Injection

SQL, NoSQL, OS command, LDAP, ORM-string. Every one of them has the same fix: never concatenate untrusted input into a query language. Parameterize, escape via the library, or validate against an allowlist.


flowchart LR
    U[User input] --> V{Boundary}
    V -->|Validate / parse| A[App layer]
    A -->|Parameterized query| DB[(DB)]
    A -->|Templated safely| SH[Shell / API]
    style V fill:#fee
      
// ❌ String concat — classic SQLi
const rows = await db.query(`SELECT * FROM users WHERE email = '${email}'`);

// ✅ Parameterized
const rows = await db.query('SELECT * FROM users WHERE email = $1', [email]);

// ✅ ORM (Prisma) — driver handles parameterization
const user = await prisma.user.findUnique({ where: { email } });

// Shell commands — never use shell:true with user input
import { execFile } from 'node:child_process';
execFile('git', ['log', '--author', userInput], (err, stdout) => { /* safe */ });

A04 — Insecure Design

Architectural mistakes you can't patch your way out of. A “forgot password” flow that emails the password back. Rate limiting only at the load balancer when business logic needs per-action limits. Storing session tokens that never rotate. Threat-model early — A04 bugs are the expensive ones.

A05 — Security Misconfiguration

Default credentials, verbose error pages in production, S3 buckets with public-read, debug endpoints exposed,X-Powered-By headers leaking versions, missing CSP. This is the cheapest category to fix and the most common entry point in breach reports.

// Express baseline — what every app should ship with
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

app.disable('x-powered-by');
app.use(helmet({ contentSecurityPolicy: { directives: { /* explicit allowlist */ } } }));
app.use(rateLimit({ windowMs: 60_000, max: 100 }));

// Hide internals — never return error.stack to clients in production
app.use((err, req, res, next) => {
  logger.error({ err, reqId: req.id });
  res.status(500).json({ error: 'internal_error', requestId: req.id });
});

A06 — Vulnerable and Outdated Components

Log4Shell, the npm event-stream backdoor, the colors.js sabotage. Pin versions, run a dependency scanner in CI (Dependabot, Snyk, Renovate), generate an SBOM, and have a documented patch SLA — critical CVE within 72 hours, high within 14 days. Covered in depth in our supply-chain security article.

A07 — Identification and Authentication Failures

Weak passwords accepted, no rate limiting on login, session IDs in URLs, predictable tokens, no MFA. The fix is largely “use a battle- tested library” — NextAuth, ASP.NET Core Identity, Devise, Passport — and turn on the protections they ship with.

A08 — Software and Data Integrity Failures

Auto-updates without signature verification, CI pipelines that deploy whatever artifact happens to land in the bucket, dependency installs from unverified sources. SLSA, Sigstore, signed container images, and verified GitHub Actions are the modern answers.

A09 — Security Logging and Monitoring Failures

Median dwell time before detection is still measured in months. Log auth events (success and failure), authorization denials, admin actions, and integrity-relevant changes. Centralize, alert on anomalies, retain at least 90 days. Don't log secrets — sanitize at the producer.

A10 — Server-Side Request Forgery (SSRF)

Any feature that fetches a URL the user supplies (webhooks, link previews, file imports, profile picture URLs) is SSRF-shaped. On cloud, SSRF becomes “hand me the instance metadata service” — the entry point for several large breaches. Defend with an outbound allowlist, not a denylist of IPs.

// SSRF guard for any user-supplied URL
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';

async function isSafeUrl(raw: string) {
  const url = new URL(raw);
  if (!['http:', 'https:'].includes(url.protocol)) return false;
  const { address } = await lookup(url.hostname);
  const addr = ipaddr.parse(address);
  const range = addr.range(); // 'private', 'loopback', 'linkLocal', 'unicast', ...
  return range === 'unicast';
}

Pitfalls

Treating the list as exhaustive

Business logic flaws (price tampering, voucher reuse, multi-step workflow bypass) rarely fit a Top 10 category but cause the largest direct revenue losses. Threat modeling fills that gap.

Scanner-only security programs

SAST and DAST tools catch maybe 40% of these categories. Manual code review around the auth and access-control layers catches another 40%. The remaining 20% needs threat modeling and red teaming.

Fixing in production without regression tests

Every security fix gets a regression test that fails without the fix and passes with it. Otherwise the fix gets reverted in the next refactor and the breach repeats — this happens more often than anyone admits.