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.
On this page
- A01: Broken Access Control
- A02: Cryptographic Failures
- A03: Injection
- A04: Insecure Design
- A05: Security Misconfiguration
- A06: Vulnerable and Outdated Components
- A07: Identification and Authentication Failures
- A08: Software and Data Integrity Failures
- A09: Security Logging and Monitoring Failures
- A10: Server-Side Request Forgery (SSRF)
- Where each risk actually bites
- A checklist you can run this sprint
- Further reading
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 loginDon'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 tableThe 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 claimedThe 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 testPin 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 tokenAlert 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
| Risk | Where it lives | Highest-leverage fix |
|---|---|---|
| A01 Access Control | API route handlers | Tenant-scoped WHERE, deny by default |
| A02 Crypto | Password/secret storage, TLS | argon2id, KMS keys, HSTS |
| A03 Injection | DB queries, shell calls | Parameterized queries, execFile |
| A04 Insecure Design | Business flows | Threat model, server-side rules |
| A05 Misconfig | Deploy, headers, CORS | Helmet, non-root container |
| A06 Components | node_modules | npm ci, audit in CI, Renovate |
| A07 Auth | Sessions, login | Library + MFA, secure cookies |
| A08 Integrity | CI/CD, CDN scripts | Pinned SHAs, SRI, SBOM |
| A09 Logging | Observability | Structured auth logs, alerts |
| A10 SSRF | Outbound fetch | Allowlist + 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:
- 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.
- Audit outbound
fetch/axioscalls for user-supplied URLs. Wrap each in an allowlist withredirect: "error". - Run
npm audit --omit=devand add it to CI so the build fails on new advisories. - Confirm passwords use argon2id or bcrypt, sessions use
httpOnly; secure; sameSite, and login is rate-limited. - Check that production errors return generic messages and security headers are set via Helmet.
- 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
- OWASP Top 10 — https://owasp.org/www-project-top-ten/
- OWASP Cheat Sheet Series (Password Storage, Authentication, SSRF) — https://cheatsheetseries.owasp.org/
- MDN Web Security — https://developer.mozilla.org/en-US/docs/Web/Security
- web.dev security guidance — https://web.dev/