All posts
Security & Best Practices··11 min read

The OWASP Top 10 for Full-Stack Developers in 2026

The OWASP Top 10 is still the baseline for web security. A full-stack, code-first walk through each risk and the concrete fix in a modern JS/TS app.

By

On this page

Most breaches I've been called in to clean up weren't exotic. Nobody chained three zero-days. Someone shipped an API route that trusted a client-supplied userId, or a fetch() that took a URL straight from a webhook payload. The OWASP Top 10 catalogs exactly these failures. OWASP shipped a 2025 revision that reshuffles the ranking and folds a few categories together, but the underlying failure modes haven't changed — Broken Access Control is still number one, and every risk below still maps to code you write. So I'm using the well-known A01–A10 framing here as a teaching spine: ten categories every full-stack engineer should be able to recite, mapped to the kind of TypeScript and SQL you actually write.

I'm going to walk all ten in order, keep it concrete, and show you the three patterns that matter most in code: a parameterized query, a server-side access-control check, and an outbound-fetch allowlist.

A01: Broken Access Control

This is number one for a reason — it's the most common serious flaw I find. The classic shape: the server authenticates who you are but never checks what you're allowed to touch.

// BROKEN — any logged-in user can read any invoice
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
  res.json(invoice);
});

requireAuth proves the caller has a valid session. It says nothing about ownership. Change the :id in the URL and you're reading someone else's invoice — an IDOR (Insecure Direct Object Reference). The fix is to scope every query to the authenticated principal and enforce it server-side, never in the client.

app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: { id: req.params.id, orgId: req.user.orgId }, // tenant scope
  });
  if (!invoice) return res.status(404).end(); // 404, not 403 — don't leak existence
  res.json(invoice);
});

Two details that matter: scope the WHERE clause to the tenant so the database does the enforcement, and return 404 rather than 403 so you don't confirm that a resource exists to someone who can't see it. Deny by default — a route with no explicit check should fail closed.

A02: Cryptographic Failures

Renamed from "Sensitive Data Exposure" because the root cause is almost always crypto done wrong: secrets in transit over plain HTTP, passwords hashed with MD5 or a fast SHA, JWTs signed with none, or homegrown encryption.

Password storage is the canonical example. Use a memory-hard KDF — argon2id is the current OWASP recommendation; bcrypt is acceptable but caps at 72 bytes.

import { hash, verify } from "@node-rs/argon2"; // native bindings, fast
 
// OWASP-aligned argon2id params (m=19456 KiB, t=2, p=1)
const opts = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
 
const stored = await hash(password, opts);          // on signup
const ok = await verify(stored, password);          // on login

Don't roll your own AES either. For application-layer encryption use a vetted library (libsodium/@noble/ciphers) with AES-256-GCM or XChaCha20-Poly1305, and store keys in a KMS, not .env. Enforce TLS 1.3 at the edge and set HSTS so downgrade attacks aren't possible:

res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");

A03: Injection

Injection is what happens when untrusted data gets interpreted as code — SQL, shell, NoSQL operators, LDAP. SQL injection is the textbook case, and string concatenation is the textbook mistake.

// BROKEN — string interpolation builds the query
const rows = await db.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${email}'`
);
// email = "x' OR '1'='1" returns the whole table

The fix isn't escaping by hand — it's parameterized queries, where the driver sends the SQL and the values on separate channels so the value can never become syntax.

-- The driver prepares this once; values bind to $1, never inlined
SELECT * FROM users WHERE email = $1;
// Tagged-template version — Prisma/postgres.js parameterize automatically
const rows = await db.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

Use your ORM's query builder or tagged templates for everything, reserve raw escape hatches for genuine need, and validate input shape with Zod at the boundary. Same rule for shell: never pass user input to exec; use execFile with an argument array so there's no shell to inject into.

A04: Insecure Design

This one is architectural, not a missing function call. It's the flaw you can't patch because the design assumed something false — no rate limit on password reset, a "buy" flow that trusts a client-sent price, a multi-step wizard you can skip to step 3.

You fix insecure design with threat modeling before you write code, and with controls baked into the flow. Concrete example: enforce limits and business rules server-side.

import rateLimit from "express-rate-limit";
 
// 5 reset attempts per 15 min per IP — abuse becomes expensive, not free
const resetLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 5 });
app.post("/api/password-reset", resetLimiter, handler);
 
// Never trust client-sent money — recompute server-side from catalog
const price = await db.product.findUniqueOrThrow({ where: { id: itemId } });
const total = price.cents * qty; // ignore whatever the client claimed

The discipline: write abuse cases alongside user stories. "As an attacker, I want to..." belongs in the same backlog as the feature.

A05: Security Misconfiguration

Default credentials, verbose stack traces in production, an S3 bucket set to public, permissive CORS, missing security headers. It's the most boringly preventable category and it shows up everywhere.

Set sane defaults with Helmet and a strict CORS policy, and make sure errors don't leak internals:

import helmet from "helmet";
import cors from "cors";
 
app.use(helmet()); // CSP, X-Frame-Options, nosniff, and more
app.use(cors({ origin: ["https://app.example.com"], credentials: true }));
 
app.use((err, _req, res, _next) => {
  logger.error(err); // full detail to logs
  res.status(500).json({ error: "Internal Server Error" }); // nothing to client
});

Audit the deployment too. A Dockerfile running as root is a misconfiguration:

FROM node:22-slim
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node                 # drop root before running
CMD ["node", "server.js"]

A06: Vulnerable and Outdated Components

Your node_modules is most of your attack surface. A single transitive dependency with a known CVE — or a malicious one slipped in via a compromised maintainer — is enough. After the wave of npm supply-chain incidents, this category is more relevant, not less.

Make dependency hygiene part of CI, not a quarterly chore:

npm audit --omit=dev          # fail the build on actionable advisories
npm outdated                  # see what's drifting
npx npm-check-updates -u      # bump intentionally, then test

Pin with a committed package-lock.json, install with npm ci (not npm install) so builds are reproducible, and run a scanner like Trivy or Snyk in CI. Enable Dependabot or Renovate for automated, reviewable PRs. The goal isn't "zero dependencies" — it's knowing what you ship and being able to patch it in hours.

A07: Identification and Authentication Failures

Weak or missing auth controls: credential stuffing with no rate limit, no MFA, predictable session tokens, sessions that never expire. If you build your own auth, you will get at least one of these wrong.

In 2026 my default advice is: don't hand-roll session management. Use a maintained library (better-auth, Auth.js, Lucia patterns) or an identity provider. When you do manage sessions, the non-negotiables:

res.cookie("session", token, {
  httpOnly: true,   // JS can't read it — blunts XSS token theft
  secure: true,     // HTTPS only
  sameSite: "lax",  // CSRF mitigation
  maxAge: 1000 * 60 * 60 * 24 * 7,
});

Use cryptographically random session IDs (crypto.randomUUID() or 256 bits from crypto.randomBytes), rotate them on privilege change, rate-limit login, and offer WebAuthn/passkeys — phishing-resistant and now well-supported across browsers.

A08: Software and Data Integrity Failures

Trusting code or data without verifying it hasn't been tampered with. Think CI/CD pipelines that pull unsigned build steps, auto-update mechanisms with no signature check, or deserializing attacker-controlled objects. The SolarWinds-class supply-chain attacks live here.

Defenses are about provenance. Use lockfile integrity (npm verifies the integrity SHA-512 hashes automatically), pin GitHub Actions to a commit SHA rather than a mutable tag, and add Subresource Integrity for any third-party script you load from a CDN:

<script
  src="https://cdn.example.com/lib@3.2.1/lib.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossorigin="anonymous"
></script>

Generate an SBOM in CI and adopt SLSA provenance for build artifacts when you can. And never eval or deserialize untrusted input into live objects.

A09: Security Logging and Monitoring Failures

You can't respond to what you can't see. The industry mean time to detect a breach is still measured in months — usually because there were no logs, or nobody watched them. Missing audit trails on auth events is the most common gap.

Log security-relevant events in structured form, never including secrets, and ship them somewhere you can alert on:

logger.warn({
  event: "auth.login.failed",
  userId: user?.id ?? null,
  ip: req.ip,
  ua: req.get("user-agent"),
  ts: new Date().toISOString(),
}); // never log the password or the session token

Alert on the patterns that matter: spikes in 401/403, repeated failures from one IP, access-control denials, and privilege changes. Make logs immutable and retain them long enough to investigate after the fact.

A10: Server-Side Request Forgery (SSRF)

SSRF is when your server makes an HTTP request to a URL the attacker controls. On cloud infrastructure it's devastating — point the request at http://169.254.169.254/ (the cloud metadata endpoint) and you can exfiltrate IAM credentials. Any feature that fetches a user-supplied URL (webhooks, "import from URL", link previews, avatars) is a candidate.

Blocklists don't work — there are too many ways to encode an IP. Use an allowlist and re-validate the resolved address to defeat DNS rebinding:

import dns from "node:dns/promises";
import net from "node:net";
 
const ALLOWED_HOSTS = new Set(["api.partner.com", "hooks.vendor.io"]);
 
async function safeFetch(rawUrl: string): Promise<Response> {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("https only");
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");
 
  // Resolve and reject private/link-local ranges (defeats DNS rebinding)
  const { address } = await dns.lookup(url.hostname);
  if (isPrivate(address)) throw new Error("private address blocked");
 
  return fetch(url, { redirect: "error" }); // never auto-follow redirects
}
 
function isPrivate(ip: string): boolean {
  if (net.isIPv4(ip)) {
    const [a, b] = ip.split(".").map(Number);
    return a === 10 || a === 127 || (a === 192 && b === 168) ||
      (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
  }
  return ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80");
}

The load-bearing details: redirect: "error" so a 200 allowlisted host can't 302 you to the metadata endpoint, and re-checking the resolved IP, not just the hostname, so a DNS record that flips to 169.254.169.254 between your check and your fetch gets caught.

Where each risk actually bites

RiskWhere it livesHighest-leverage fix
A01 Access ControlAPI route handlersTenant-scoped WHERE, deny by default
A02 CryptoPassword/secret storage, TLSargon2id, KMS keys, HSTS
A03 InjectionDB queries, shell callsParameterized queries, execFile
A04 Insecure DesignBusiness flowsThreat model, server-side rules
A05 MisconfigDeploy, headers, CORSHelmet, non-root container
A06 Componentsnode_modulesnpm ci, audit in CI, Renovate
A07 AuthSessions, loginLibrary + MFA, secure cookies
A08 IntegrityCI/CD, CDN scriptsPinned SHAs, SRI, SBOM
A09 LoggingObservabilityStructured auth logs, alerts
A10 SSRFOutbound fetchAllowlist + resolved-IP check

A checklist you can run this sprint

You don't need to fix all ten at once. Prioritize by what's both common and catastrophic:

  1. Grep every route handler for an authorization check. Any route that loads a record by ID without scoping to the current tenant is an A01 bug — fix those first.
  2. Audit outbound fetch/axios calls for user-supplied URLs. Wrap each in an allowlist with redirect: "error".
  3. Run npm audit --omit=dev and add it to CI so the build fails on new advisories.
  4. Confirm passwords use argon2id or bcrypt, sessions use httpOnly; secure; sameSite, and login is rate-limited.
  5. Check that production errors return generic messages and security headers are set via Helmet.
  6. Verify auth failures and access denials are logged and that someone gets alerted on a spike.

Security isn't a feature you finish; it's a property you maintain. The Top 10 is the floor, not the ceiling — but a team that genuinely closes these ten is already ahead of most of the production code I've audited in 17 years.

Further reading