All articles
Security Node.js Next.js Web Development OWASP

OWASP Top 10 in a Real Node.js & Next.js App — Before/After

Palakorn Voramongkol
March 11, 2026 15 min read

“The 2021 OWASP Top 10, but with actual vulnerable Node.js and Next.js snippets and the fixes I ship in production. Concrete examples beat awareness posters.”

The OWASP Top 10 is the most cited security checklist in web development, and the most under-applied. Awareness paragraphs don’t tell you what the bug looks like in the route handler you wrote last Tuesday. This post walks every category with a real-shaped Node.js / Next.js snippet and the fix I actually ship.

TL;DR

  • Authorization belongs next to the database call — scope every query by the caller’s id, not just by an authenticated session.
  • Store sessions in HttpOnly; Secure; SameSite cookies and hash passwords with Argon2id (or scrypt / bcrypt cost 12+), never SHA.
  • Parameterise every query — SQL, NoSQL, and shell — and validate request bodies through a schema (Zod / Valibot) before they reach a driver.
  • Design for abuse: CSPRNG-issued single-use tokens, rate-limits keyed by both IP and username, MFA on anything valuable.
  • Lock the supply chain: npm audit in CI, Renovate for upgrades, SBOM + signed artefacts, prototype-pollution-safe merges.
  • Log auth events, 403s, and admin actions with redaction and a request-id; never log raw bodies or tokens.
  • For SSRF, combine a scheme + host allow-list with a DNS resolution check and redirect: "manual".

Why Another OWASP Post

The OWASP Top 10 is one of those lists everyone has read and nobody has internalised. Most write-ups stop at the category name and a paragraph of awareness text — “don’t trust user input, validate your data, rotate your secrets.” That’s wallpaper. It doesn’t tell you what the bug actually looks like in the Next.js route handler you wrote last Tuesday.

This post is my attempt at the opposite. For each category in the 2021 list I’ll show a Pain case — a real-shaped snippet of Node.js or Next.js code that I or someone near me has shipped at least once — and the Fix that I actually push to production. The focus is web applications (Next.js App Router, Prisma, NextAuth, MongoDB, Redis), not APIs in the abstract.

The 2021 categories, in order:

  • 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

Every code sample below is TypeScript on Node.js unless labelled otherwise. One Python parallel in A03 shows that the shape of the bug is language-independent.

A01 — Broken Access Control

Top of the list for good reason. It’s the bug where the server correctly authenticates the user, then forgets to check whether that user is allowed to touch this particular resource.

The classic shape is IDOR — Insecure Direct Object Reference. The route reads an id from the URL and returns the record without checking ownership.

The pain case

// app/api/invoices/[id]/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) return new NextResponse("Unauthorized", { status: 401 });

  // Authenticated — but no authorization check.
  const invoice = await prisma.invoice.findUnique({ where: { id: params.id } });
  return NextResponse.json(invoice);
}

Any logged-in user can enumerate /api/invoices/abc123 and read every invoice in the system. The session check gates entry to the building; it doesn’t gate entry to the room.

The fix

Authorization lives beside the query, and the query itself scopes the result to the caller.

// app/api/invoices/[id]/route.ts
export async function GET(_: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session?.user?.id) return new NextResponse("Unauthorized", { status: 401 });

  const invoice = await prisma.invoice.findFirst({
    where: { id: params.id, ownerId: session.user.id },
  });
  if (!invoice) return new NextResponse("Not found", { status: 404 });
  return NextResponse.json(invoice);
}

Two changes matter:

  1. The where clause pins the record to the caller. A request for someone else’s invoice returns 404, not 403 — you don’t want to confirm the id exists.
  2. The check cannot be bypassed by adding query params or tweaking the body, because it’s a column on the row.

For non-trivial apps this gets centralised. I keep an authorize(session, action, resource) helper and call it from every handler that touches owned data. Route middleware is a decent second line, but the real enforcement lives next to the database call — that’s the last place a bug can hide.

Server Actions are not free

Next.js Server Actions look like RPC but run on the same shared HTTP transport. A Server Action that forgets the ownership check is exactly the same bug as the REST version above. Treat every action like a public endpoint, because it is one.

A02 — Cryptographic Failures

Not using crypto is rare. Using it wrong is the normal case.

Two flavours dominate in web apps: storing secrets on the client where they can be stolen, and hashing passwords with algorithms that a GPU can crack for lunch.

The pain case — tokens in localStorage

// A common pattern from the SPA era.
const res = await fetch("/api/login", { method: "POST", body: JSON.stringify(creds) });
const { accessToken } = await res.json();
localStorage.setItem("token", accessToken);

// Later:
fetch("/api/me", {
  headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});

localStorage is readable by any JavaScript that runs on your origin. One XSS in a third-party analytics snippet, one compromised npm package, one dangerouslySetInnerHTML that someone got wrong, and every user’s long-lived token leaves the building.

The fix — HttpOnly, Secure, SameSite cookies

// app/api/login/route.ts
import { cookies } from "next/headers";
import { SignJWT } from "jose";

export async function POST(req: Request) {
  const creds = await req.json();
  const user = await verifyCredentials(creds);
  if (!user) return new NextResponse("Invalid", { status: 401 });

  const token = await new SignJWT({ sub: user.id })
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("15m")
    .sign(SECRET);

  cookies().set("session", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 15,
  });
  return NextResponse.json({ ok: true });
}

httpOnly means JavaScript cannot read the cookie. secure means it never leaves the browser over plaintext HTTP. sameSite: "lax" blocks most CSRF. Pair this with short token lifetimes and a refresh-token rotation scheme (also in an HttpOnly cookie, on a different path) and the token-theft attack class shrinks dramatically.

The pain case — weak password hashing

import { createHash } from "node:crypto";
// Don't.
const hash = createHash("sha256").update(password).digest("hex");
await prisma.user.create({ data: { email, hash } });

SHA-256 is fast. That’s the problem. On modern hardware an attacker with a leaked users table can try hundreds of millions of candidate passwords per second. Salts don’t save you; the attacker just salts too.

The fix — a memory-hard KDF

import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2";

// Registration
const hash = await argon2Hash(password, {
  memoryCost: 19456,  // 19 MiB
  timeCost: 2,
  parallelism: 1,
  algorithm: 2,       // Argon2id
});
await prisma.user.create({ data: { email, hash } });

// Login
const ok = await argon2Verify(stored.hash, submitted);

Argon2id (or scrypt, or bcrypt with a cost of 12+) is memory-hard. The attacker can’t throw GPUs at the problem the way they can with SHA. The parameters above are a starting point — tune memoryCost and timeCost on your production box so a single verification takes ~100–250 ms, then live with that.

A03 — Injection

Still on the list three decades after it arrived, because the pattern — “build a string, hand it to an interpreter” — keeps reappearing in new interpreters.

Three common shapes in a Node.js stack.

SQL injection via raw Prisma

Prisma’s typed API is safe. The escape hatches are not.

// Pain: user-controlled string in a raw query.
export async function searchUsers(q: string) {
  return prisma.$queryRawUnsafe(
    `SELECT id, email FROM "User" WHERE email LIKE '%${q}%'`
  );
}

The Unsafe in the method name is not decorative. A request with q = "' OR '1'='1" dumps the table.

// Fix: parameterised tagged template.
export async function searchUsers(q: string) {
  return prisma.$queryRaw<
    { id: string; email: string }[]
  >`SELECT id, email FROM "User" WHERE email LIKE ${`%${q}%`}`;
}

$queryRaw with a tagged template sends parameters separately from the SQL text; the driver handles escaping. If you find yourself reaching for $queryRawUnsafe, stop and ask whether prisma.user.findMany({ where: { email: { contains: q } } }) isn’t what you wanted all along.

The same bug in Python

// Node.js — psycopg-style pseudocode, same bug shape.
await client.query(`SELECT * FROM users WHERE email = '${email}'`);
// Fix:
await client.query("SELECT * FROM users WHERE email = $1", [email]);
# Python — the bug is identical at the string-building layer.
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")  # vulnerable
# Fix: parameterise.
cur.execute("SELECT * FROM users WHERE email = %s", (email,))

The Node and Python versions are the same bug wearing different syntax. Any language that lets you concatenate a string and hand it to a SQL driver has this class of bug; any driver that accepts parameterised queries has the fix.

NoSQL injection via MongoDB filters

MongoDB accepts objects as filters. If you parse a JSON body and spread it into a query, a client can inject operators.

// Pain: user body spread into filter.
const body = await req.json();
const user = await db.collection("users").findOne(body);
// Client sends: { "email": "a@b.com", "password": { "$ne": null } }
// Result: login bypass.
// Fix: whitelist with a schema.
import { z } from "zod";
const Login = z.object({ email: z.string().email(), password: z.string().min(1) });
const { email, password } = Login.parse(await req.json());
const user = await db.collection("users").findOne({ email });
if (!user || !(await argon2Verify(user.hash, password))) return reject();

Two rules keep this class of bug away:

  1. Never pass raw request JSON to a Mongo filter. Parse it into a typed object first.
  2. Disable object-ish types on string fields with a schema validator (Zod, Valibot, io-ts — pick one).

Command injection via child_process

// Pain: shelling out with user input.
import { exec } from "node:child_process";
exec(`convert ${userInput} out.png`, (err, stdout) => { /* ... */ });

exec invokes a shell. A filename like ; rm -rf / # does what it says. The fix is execFile (or spawn) with an argv array — no shell involved.

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);

// Validate the argument first.
if (!/^[\w.-]+\.(png|jpg|webp)$/.test(userInput)) throw new Error("bad filename");

await run("convert", [userInput, "out.png"]);

If your “file” came from an upload, hash-and-rename it on disk before you ever pass its name to another process. The input to convert is then a server-generated name that no attacker can influence.

A04 — Insecure Design

Injection is a bug in code. Insecure design is a bug in the picture on the whiteboard.

Two examples that recur in web apps.

The pain case — predictable password reset tokens

// Pain: looks random, isn't.
import { randomBytes } from "node:crypto";
const token = Date.now().toString(36) + Math.random().toString(36).slice(2);
await prisma.passwordReset.create({ data: { userId, token, expiresAt } });

Math.random() is not a CSPRNG. The timestamp leaks when the token was generated. An attacker who knows roughly when a user requested a reset can brute-force the token space in minutes.

The fix

import { randomBytes, createHash } from "node:crypto";

export async function issueResetToken(userId: string) {
  const raw = randomBytes(32).toString("base64url");          // 256 bits of entropy
  const hash = createHash("sha256").update(raw).digest("hex");
  await prisma.passwordReset.create({
    data: { userId, tokenHash: hash, expiresAt: new Date(Date.now() + 30 * 60_000) },
  });
  return raw; // emailed to the user; never logged, never stored
}

export async function consumeResetToken(raw: string) {
  const hash = createHash("sha256").update(raw).digest("hex");
  const row = await prisma.passwordReset.findFirst({
    where: { tokenHash: hash, usedAt: null, expiresAt: { gt: new Date() } },
  });
  if (!row) throw new Error("invalid or expired");
  await prisma.passwordReset.update({ where: { id: row.id }, data: { usedAt: new Date() } });
  return row.userId;
}

Three design properties to notice:

  1. The token is 256 bits from a CSPRNG.
  2. The database stores a hash of the token, not the token itself. A database leak doesn’t give the attacker a set of live reset links.
  3. Tokens are single-use and expire on a short timer.

The pain case — no rate limit on login

A login endpoint with no rate limit is a credential-stuffing playground. An attacker who bought a leaked password dump from somewhere else can test thousands of (email, password) pairs per second against your site until something sticks.

The fix

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const loginLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "15 m"),
  analytics: true,
  prefix: "rl:login",
});

// app/api/login/route.ts
export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
  const { email } = await req.json();
  const { success } = await loginLimiter.limit(`${ip}:${email.toLowerCase()}`);
  if (!success) return new NextResponse("Too many attempts", { status: 429 });
  // ...verify credentials...
}

Rate-limit keyed by both IP and username, not just one. IP-only lets an attacker hop IPs per user. Username-only lets them flood many usernames from a single IP. The combined key makes both shapes expensive.

Layer this with CAPTCHA after N failures and, for sensitive accounts, an email alert on suspicious login.

A05 — Security Misconfiguration

The category that catches you when you’re tired and shipping at 23:00.

Overbroad CORS

// Pain: reflecting every origin.
export function middleware(req: NextRequest) {
  const res = NextResponse.next();
  res.headers.set("Access-Control-Allow-Origin", req.headers.get("origin") ?? "*");
  res.headers.set("Access-Control-Allow-Credentials", "true");
  return res;
}

Allow-Origin: * with Allow-Credentials: true is actually rejected by modern browsers — but the reflected-origin pattern is worse. Any site, on any domain, can make authenticated requests to your API with the user’s cookies attached. That’s every CSRF writeup, but with extra steps.

// Fix: a small allow-list.
const ALLOWED = new Set(["https://palakorn.com", "https://admin.palakorn.com"]);
export function middleware(req: NextRequest) {
  const origin = req.headers.get("origin");
  const res = NextResponse.next();
  if (origin && ALLOWED.has(origin)) {
    res.headers.set("Access-Control-Allow-Origin", origin);
    res.headers.set("Vary", "Origin");
    res.headers.set("Access-Control-Allow-Credentials", "true");
  }
  return res;
}

Verbose error pages

Next.js defaults are fine in production — stack traces don’t leak. The bug is usually custom error handlers that return err.message or err.stack to the client “to help debugging.” They help the attacker more. Log the full error server-side, return a redacted message and a correlation id to the user.

export function errorResponse(err: unknown) {
  const id = crypto.randomUUID();
  logger.error({ id, err }, "unhandled");
  return NextResponse.json({ error: "Internal error", id }, { status: 500 });
}

Security headers

next.config.js headers, or a middleware res.headers.set(...):

const securityHeaders = {
  "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
  "X-Content-Type-Options": "nosniff",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
  "Content-Security-Policy":
    "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
};

A restrictive CSP is the single biggest defence against stored XSS turning into token theft.

The .env that went public

The one that keeps me awake: .env ending up in a Docker image, a build artefact, or a next export output. Two habits prevent it:

  1. Never read .env directly in code — read process.env and fail loudly if required keys are missing (no defaults, no fallbacks).
  2. A CI step that greps the built artefact for known secret patterns (SK_LIVE_, AKIA, -----BEGIN) and fails the build.

A06 — Vulnerable and Outdated Components

Your lockfile is the largest piece of code in your repo, and you wrote none of it.

Three practices that have paid for themselves on every project I’ve run.

npm audit in CI, with a pinned threshold

# .github/workflows/audit.yml
- name: npm audit
  run: npm audit --audit-level=high

--audit-level=high means the build fails on High or Critical advisories. moderate is too noisy for most teams; critical is too lax. Review the exceptions weekly.

Renovate, not hand-updates

A Renovate config that batches minor and patch updates weekly and raises majors as separate PRs means dependency upgrades happen in small, reviewable chunks rather than one “bump everything” PR every six months.

{
  "extends": ["config:recommended"],
  "schedule": ["before 6am on Monday"],
  "packageRules": [
    { "matchUpdateTypes": ["minor", "patch"], "groupName": "all non-major" },
    { "matchUpdateTypes": ["major"], "labels": ["major-upgrade"] }
  ],
  "vulnerabilityAlerts": { "labels": ["security"], "schedule": ["at any time"] }
}

SBOM and GHSA monitoring

Generate a CycloneDX or SPDX SBOM in CI (npm sbom --sbom-format cyclonedx or syft) and upload it with the build. GitHub’s Dependabot already watches GHSA; if you’re not on GitHub, wire the SBOM into Grype or OSV-Scanner on a schedule.

- run: npm sbom --sbom-format cyclonedx --sbom-type application > sbom.json
- uses: anchore/scan-action@v4
  with: { sbom: sbom.json, fail-build: true, severity-cutoff: high }

A07 — Identification and Authentication Failures

Login is a surprisingly easy thing to get 80% right and 100% wrong.

Weak session cookies

Seen this in a live app:

// Pain.
res.setHeader("Set-Cookie", `sid=${sessionId}`);

No HttpOnly, no Secure, no SameSite, no Path, no expiry. An opportunistic XSS reads it; an opportunistic HTTP intercept steals it. Use the cookies() API (or cookie package) and set the full attribute set shown in A02.

No account lockout / no MFA

Password-only auth on anything valuable is a 2025 mistake. For web apps I reach for:

  • TOTP via otplib as a first factor beyond password, with backup codes.
  • WebAuthn / Passkeys where the user is willing, via @simplewebauthn/server.

A minimal TOTP enrolment:

import { authenticator } from "otplib";

export async function enrollTotp(userId: string) {
  const secret = authenticator.generateSecret();
  await prisma.user.update({ where: { id: userId }, data: { totpSecret: encrypt(secret) } });
  return authenticator.keyuri(userEmail, "palakorn.com", secret);
}

export async function verifyTotp(userId: string, code: string) {
  const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
  return authenticator.verify({ token: code, secret: decrypt(user.totpSecret) });
}

The encrypt/decrypt wrappers use a server-side AEAD key, not the user’s password. That way a database leak without the app key doesn’t hand over every TOTP seed.

Credential stuffing defence

A07 overlaps with A04 here. Rate-limit, CAPTCHA after threshold, and — the cheapest win of all — check the submitted password against the Have I Been Pwned k-anonymity API on registration and password change. Users hate that UX; they will hate “your data was leaked” more.

A08 — Software and Data Integrity Failures

The supply-chain category. 2021 added it after SolarWinds; it earns its slot in every post-mortem I’ve read since.

Unsigned build artefacts

If your deploy pipeline pulls a tarball from a public URL and runs it, the URL is your trust boundary. Sign artefacts with Sigstore / cosign, and verify on the production host before unpacking.

# CI
- run: cosign sign-blob --yes dist.tar.gz --output-signature dist.tar.gz.sig

# Deploy host
- run: cosign verify-blob --signature dist.tar.gz.sig --certificate-identity-regexp '...' dist.tar.gz

The same principle applies to container images (cosign verify before docker pull), Lambda zips, and any binary that crosses a network.

Arbitrary JSON parsed from user input — prototype pollution

// Pain: deep-merging untrusted input.
import merge from "lodash.merge";
const config = merge({}, defaults, req.body);

A body like { "__proto__": { "isAdmin": true } } pollutes Object.prototype for the rest of the process. Every object created afterwards inherits isAdmin = true until restart.

// Fix 1: use a schema and only copy known fields.
const Patch = z.object({ theme: z.enum(["light", "dark"]), locale: z.string() });
const safe = Patch.parse(req.body);
const config = { ...defaults, ...safe };

// Fix 2: if you must deep-merge, use a library that guards __proto__.
//   `defu` and `deepmerge-ts` both reject dunder keys by default.

Node 22+ also supports --disable-proto=delete which removes Object.prototype.__proto__ entirely; worth adding to your production flags.

JSON.parse is not the only vector

yaml.load (the unsafe default of js-yaml in older versions), node-serialize (the infamous one), and any library that calls eval on config data all belong in the same bucket. The rule is the same as A03: never hand user data to a parser that can create arbitrary objects.

A09 — Security Logging and Monitoring Failures

The category you notice after the incident, not before.

The goal isn’t “log everything.” It’s to log enough that, six hours into an incident, you can answer: who logged in, from where, what did they touch, and did anything else in the stack behave oddly at the same time.

What to log

At minimum:

  • Authentication events: login success, login failure, password change, MFA enrolment, password reset request + use, session revocation.
  • Authorization denials: every 403. 403s are not normal traffic; if you see a lot, someone is probing.
  • Administrative actions: every write from an admin account, with the resource id and old/new values.
  • Server-side errors with a correlation id, tied to the user id when safe.

The pain case — logging the whole request body

// Pain.
logger.info({ body: await req.json() }, "login attempt");

Now your log aggregator has plaintext passwords and anything else the user submitted.

The fix — redaction at the logger

Pino’s redact option is a scalpel:

import pino from "pino";

export const logger = pino({
  redact: {
    paths: [
      "password", "*.password",
      "token", "*.token",
      "authorization", "headers.authorization",
      "req.headers.cookie",
      "email",            // PII; hash or drop
    ],
    censor: "[REDACTED]",
  },
});

For PII like email addresses, I log a stable hash (sha256(email + pepper)) rather than the plaintext. That preserves correlation across log lines without putting identifiers in the SIEM.

Trace correlation

Every log line gets a request id. Every response gets the same id in a header (X-Request-Id). When a user reports a problem they give you the id from the error page; you find the request instantly. Middleware:

export function middleware(req: NextRequest) {
  const id = req.headers.get("x-request-id") ?? crypto.randomUUID();
  const res = NextResponse.next({ request: { headers: new Headers(req.headers) } });
  res.headers.set("x-request-id", id);
  return res;
}

Pair this with OpenTelemetry if you’re on a distributed stack — the trace id is the request id and the DB span and the outbound HTTP call are all on one timeline.

A10 — Server-Side Request Forgery

The one that turns a “fetch this image for me” feature into an AWS metadata credential leak.

The pain case

// app/api/proxy-image/route.ts
export async function GET(req: Request) {
  const url = new URL(req.url).searchParams.get("url")!;
  const res = await fetch(url);
  return new NextResponse(res.body, { headers: { "content-type": res.headers.get("content-type") ?? "" } });
}

A request to /api/proxy-image?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ on AWS returns the node’s IAM credentials. A request to http://localhost:6379/ against a Redis with no auth lets the attacker speak Redis protocol via HTTP smuggling. A request to http://internal-admin.svc.cluster.local/ reaches services that were supposed to be private.

The fix — a strict allow-list

import { LookupAddress, lookup } from "node:dns";
import { promisify } from "node:util";
import net from "node:net";

const dnsLookup = promisify(lookup);

const ALLOWED_HOSTS = new Set(["images.unsplash.com", "cdn.palakorn.com"]);

function isPrivate(ip: string): boolean {
  if (net.isIPv4(ip)) {
    const [a, b] = ip.split(".").map(Number);
    return (
      a === 10 ||
      a === 127 ||
      (a === 169 && b === 254) ||
      (a === 172 && b >= 16 && b <= 31) ||
      (a === 192 && b === 168)
    );
  }
  // IPv6 loopback, link-local, unique-local, IPv4-mapped.
  return /^(::1|fe80:|fc|fd|::ffff:)/i.test(ip);
}

async function safeFetch(raw: string): Promise<Response> {
  const url = new URL(raw);
  if (url.protocol !== "https:") throw new Error("https only");
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");

  const { address } = await dnsLookup(url.hostname);
  if (isPrivate(address)) throw new Error("private address");

  const res = await fetch(url, { redirect: "manual" });
  if (res.status >= 300 && res.status < 400) throw new Error("no redirects");
  return res;
}

Three defences layered:

  1. Scheme + host allow-list. The easy 90%.
  2. DNS resolution check to catch attacker.com that resolves to 127.0.0.1. Without this, the hostname check is bypassable.
  3. No automatic redirects. Otherwise a 302 from the allowed host to 169.254.169.254 defeats step 2.

There’s still a TOCTOU window between the DNS lookup and the connect — an attacker who controls the DNS record can flip the answer. The paranoid fix is to resolve once, then connect directly to the resolved IP with a Host: header override. Most apps don’t need that; if you’re fetching from a public CDN you trust, you don’t.

For stronger isolation, run the fetcher in a separate process with no network access to internal ranges (NetworkPolicy in Kubernetes, a locked-down VPC subnet, or bubblewrap on a bare host).

Closing Checklist — Wire These Into CI

A security posture is only as strong as the checks that run without you remembering.

  • Static analysis. ESLint with eslint-plugin-security and eslint-plugin-n, tsc --noEmit, and semgrep with the javascript.express and typescript.nextjs rulesets.
  • Secret scanning. gitleaks on every push, and a pre-commit hook locally.
  • Dependency audit. npm audit --audit-level=high in CI, Renovate weekly, SBOM + Grype/OSV on build.
  • Container / image scan. trivy image on the deploy artefact, failing on High.
  • DAST on staging. ZAP Baseline Scan against a running staging deployment, at least weekly.
  • Security headers. A test that hits / and asserts the full header set (HSTS, CSP, XCTO, Referrer-Policy).
  • Rate-limit test. An integration test that hammers /api/login with bad credentials and asserts the 429.
  • Logging test. An assertion that no log line from a known route contains the literal password submitted in the test.
  • Auth matrix test. For every owned resource, a test where user A tries to read user B’s record and expects 404.

Most of these are one line of YAML each. The return on that line is enormous — the difference between “we have a process” and “we have a posture.”

Final Note

None of this is a zero-day. Every category above has been on the Top 10 for a decade or more, and every fix has been production-grade for years. The reason the list keeps topping itself is that security is a property of the whole system, not any individual commit. Each of these fixes is small in isolation. Wired into CI, code review, and muscle memory, they’re what separates an app that shrugs off a breach report from one that makes the news.

Read the OWASP cheat sheets when you have a spare hour — they’re excellent. But don’t stop at reading. Open the last route handler you wrote, grep for findUnique, and check the where. That’s where the work lives.

Further Reading

  • OWASP Top 10 (2021) — the source list, with category descriptions and CWE mappings.
  • OWASP Cheat Sheet Series — practical, language-agnostic guidance for authentication, authorization, cryptography, and more.
  • NIST SP 800-63B — Digital Identity Guidelines — the modern reference for password, MFA, and session policy.
  • The Tangled Web — Michal Zalewski (2011). The clearest book-length tour of how browsers and web protocols actually behave under attack.
  • Web Application Hacker’s Handbook, 2nd ed. — Stuttard & Pinto (2011). Old but unmatched as an end-to-end catalogue of web attack techniques.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading