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

OWASP refreshed the list for 2025 — first update since 2021. Two new categories, one full merge, and several categories reshuffled by rank. The categories below are the current edition; knowing them changes how you structure code, not just how you patch it.

A01 — Broken Access Control

The #1 category for a reason, and it stayed #1 in the 2025 refresh. 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);
});

SSRF Lives Here Now

The 2025 edition folded Server-Side Request Forgery into Broken Access Control — it's an access-control failure at heart: code that fetches a URL the user supplies (webhooks, link previews, file imports, profile picture URLs) is trusting that user with more network access than they should have. 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';
}

A02 — Security Misconfiguration

The biggest mover in the 2025 list — up from #5 to #2, because continuous deployment without continuous scanning creates a constant exposure window. 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 one of the most common entry points 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 });
});

A03 — Software Supply Chain Failures

A new category name for 2025, and a broader one — it replaces “Vulnerable and Outdated Components” and expands the scope past known CVEs in your direct dependencies to cover the whole chain: build systems, distribution infrastructure, and unknown vulnerabilities introduced by third parties, not just the ones with a CVE number already attached. 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.

A04 — 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

A05 — 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 */ });

A06 — 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 — these bugs are the expensive ones. The 2025 data shows this category actually improving industry-wide as threat modeling and secure-design practices spread — proof that the earlier categories on this list are the ones worth investing in first.

A07 — Authentication Failures

Renamed from “Identification and Authentication Failures” in 2025, same substance: 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 or 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. Distinct from A03's supply-chain concern: this category is about trust boundaries and verification at your own implementation level — SLSA, Sigstore, signed container images, and verified GitHub Actions are the modern answers.

A09 — Security Logging & Alerting Failures

Renamed in 2025 to put alerting on equal footing with logging — a log nobody is paged on might as well not exist. 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 — Mishandling of Exceptional Conditions

New for 2025, and it replaces SSRF (now folded into A01) as the tenth slot. Not every bug is injection or a missing access check — sometimes the vulnerability is what your code does when something goes wrong. A caught-and-swallowed exception that leaves a transaction half-applied. An error path that skips the check the happy path enforces. A retry that turns a transient failure into a duplicate charge. 24 CWEs sit under this category, and most of them are logic bugs that only surface once you stop assuming the happy path.

// ❌ Exception swallowed — request "succeeds" without the second half completing
async function transfer(req, res) {
  try {
    await debit(req.body.from, req.body.amount);
    await credit(req.body.to, req.body.amount);
  } catch (err) {
    logger.error({ err }); // logged and ignored — money already left the first account
  }
  res.sendStatus(200);
}

// ✅ Fail closed — an error anywhere aborts the whole operation and reports it
async function transfer(req, res) {
  try {
    await db.$transaction([
      debit(req.body.from, req.body.amount),
      credit(req.body.to, req.body.amount),
    ]);
  } catch (err) {
    logger.error({ err, reqId: req.id });
    return res.status(500).json({ error: 'transfer_failed', requestId: req.id });
  }
  res.sendStatus(200);
}

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.