How to Audit and Fix OWASP Top 10 Security Vulnerabilities in a Node.js Enterprise API
How to use this tutorial
This walks the OWASP Top 10 (2021 edition) categories that most commonly appear in a Node.js/Express enterprise API, in the order you should actually check them during an audit — starting with access control and injection, which tend to be both the most common and the most severe. Each section shows the vulnerable pattern, the fix, and how to verify the fix with an automated check.
1. Technical architecture (audit workflow)
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────────┐
│ Static analysis │──▶│ Dependency audit │──▶│ Manual/dynamic review │
│ (ESLint security │ │ (npm audit, Snyk, │ │ (auth flows, SSRF, │
│ plugin, Semgrep) │ │ OSV-Scanner) │ │ business logic) │
└─────────────────┘ └──────────────────────┘ └────────────┬───────────┘
▼
┌────────────────────────┐
│ Findings list, mapped to │
│ OWASP Top 10 categories │
│ with severity + CWE ID │
└────────────┬───────────┘
▼
┌────────────────────────┐
│ Remediation PRs + regression│
│ tests (one per finding) │
└────────────────────────┘
2. Complete code implementation (findings and fixes)
2.1 A01: Broken Access Control
// VULNERABLE: trusts a client-supplied user id instead of the authenticated session
app.get("/api/orders/:userId", authenticate, async (req, res) => {
const orders = await db.query("SELECT * FROM orders WHERE user_id = ?", [req.params.userId]);
res.json(orders);
});
// FIXED: authorization derived from the verified session, never from client input
app.get("/api/orders/:userId", authenticate, async (req, res) => {
const requestedUserId = req.params.userId;
const isSelf = req.user.id === requestedUserId;
const isAdmin = req.user.role === "admin";
if (!isSelf && !isAdmin) {
return res.status(403).json({ error: "Forbidden" });
}
const orders = await db.query("SELECT * FROM orders WHERE user_id = ?", [requestedUserId]);
res.json(orders);
});
2.2 A02/A03: Cryptographic failures and Injection
// VULNERABLE: string-concatenated SQL, plaintext password storage
async function createUser(email, password) {
await db.query(`INSERT INTO users (email, password) VALUES ('${email}', '${password}')`);
}
// FIXED: parameterized query, salted hashing with bcrypt
const bcrypt = require("bcryptjs");
async function createUser(email, password) {
if (password.length < 12) {
throw new ValidationError("Password must be at least 12 characters");
}
const passwordHash = await bcrypt.hash(password, 12); // cost factor 12
await db.query("INSERT INTO users (email, password_hash) VALUES (?, ?)", [email, passwordHash]);
}
2.3 A05: Security misconfiguration (headers, CORS)
// FIXED: security headers and a strict, explicit CORS allowlist
const helmet = require("helmet");
const cors = require("cors");
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
}));
const ALLOWED_ORIGINS = ["https://app.example.com", "https://admin.example.com"];
app.use(cors({
origin: (origin, callback) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
return callback(null, true);
}
callback(new Error("Not allowed by CORS"));
},
credentials: true,
}));
// Never leak stack traces or internal error detail to clients
app.use((err, req, res, next) => {
console.error(err); // full detail goes to server-side logs only
const status = err.status || 500;
res.status(status).json({ error: status === 500 ? "Internal server error" : err.message });
});
2.4 A08/A10: Insecure deserialization and SSRF
// VULNERABLE: fetches an arbitrary user-supplied URL server-side
app.post("/api/fetch-preview", authenticate, async (req, res) => {
const response = await fetch(req.body.url); // can hit internal metadata endpoints, private IPs
const html = await response.text();
res.json({ preview: html.slice(0, 500) });
});
// FIXED: allowlist scheme/host, block private/link-local ranges, cap redirects
const { URL } = require("url");
const net = require("net");
const dns = require("dns").promises;
const BLOCKED_HOST_PATTERNS = [/^127\./, /^10\./, /^192\.168\./, /^169\.254\./, /^::1$/, /^localhost$/i];
async function isSafeUrl(rawUrl) {
let parsed;
try {
parsed = new URL(rawUrl);
} catch {
return false; // not a valid URL at all
}
if (!["http:", "https:"].includes(parsed.protocol)) return false;
const addresses = await dns.resolve4(parsed.hostname).catch(() => []);
const allTargets = [parsed.hostname, ...addresses];
for (const target of allTargets) {
if (BLOCKED_HOST_PATTERNS.some((pattern) => pattern.test(target))) return false;
if (net.isIP(target) && isPrivateIp(target)) return false;
}
return true;
}
function isPrivateIp(ip) {
const octets = ip.split(".").map(Number);
if (octets.length !== 4) return false;
const [a, b] = octets;
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 169;
}
app.post("/api/fetch-preview", authenticate, async (req, res) => {
const { url } = req.body;
if (!(await isSafeUrl(url))) {
return res.status(400).json({ error: "URL not allowed" });
}
try {
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
if ([301, 302, 303, 307, 308].includes(response.status)) {
return res.status(400).json({ error: "Redirects are not followed for preview fetches" });
}
const html = await response.text();
res.json({ preview: html.slice(0, 500) });
} catch (err) {
if (err.name === "TimeoutError") {
return res.status(504).json({ error: "Fetch timed out" });
}
res.status(502).json({ error: "Failed to fetch URL" });
}
});
2.5 A07: Identification and authentication failures — rate limiting
// FIXED: rate limit auth endpoints specifically, separate from general API limits
const rateLimit = require("express-rate-limit");
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many authentication attempts, please try again later" },
});
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/reset-password", authLimiter);
3. Step-by-step configuration guide
- Install audit and remediation tooling:
npm install helmet cors express-rate-limit bcryptjs npm install -D eslint eslint-plugin-security - Run dependency and static scans first, before touching code:
npm audit --production npx eslint . --ext .js --no-eslintrc -c .eslintrc.security.json - Triage findings by OWASP category and severity — fix Broken Access Control and Injection findings first; they tend to carry the highest real-world exploitation risk in typical enterprise APIs.
- Apply the access-control fix pattern (§2.1) to every route that takes a resource ID from the URL/body — grep for
req.paramsandreq.bodyusages that reach a database query without an accompanying ownership/role check. - Replace every string-concatenated query with parameterized queries (§2.2); most ORMs (Prisma, Sequelize, Knex) parameterize by default, so this usually means finding raw
db.query/connection.executecalls with template literals. - Add
helmet, a strict CORS allowlist, and a generic error handler globally, as early as possible in your middleware chain (§2.3). - Audit every server-side outbound fetch (webhooks, URL previews, image proxies, PDF generators) for SSRF using the
isSafeUrlpattern in §2.4 — this is the finding most often missed in manual review because it doesn't look dangerous at a glance. - Add rate limiting to authentication-adjacent endpoints specifically (§2.5), not just a blanket API-wide limiter, since credential-stuffing and password-reset abuse target those routes disproportionately.
- Re-run the scans from step 2 and confirm each finding is resolved before closing it out; add a regression test per fixed finding so it can't silently regress in a future PR.
4. Error handling and edge cases
- DNS rebinding in the SSRF fix —
isSafeUrlresolves the hostname and checks the resolved IPs, but a hostname could resolve to a public IP at check-time and a private IP at request-time (DNS rebinding). For high-risk fetch endpoints, pin the resolved IP and connect to it directly (or route the fetch through a dedicated egress proxy that re-validates on every connection) rather than trusting a single resolve-then-fetch sequence. - Redirect-based SSRF bypass — the fixed
/api/fetch-previewhandler usesredirect: "manual"and explicitly rejects redirect status codes rather than following them, since an attacker-controlled redirect target is a common way to bypass an initial URL allowlist check. - Error messages leaking implementation detail — the global error handler in §2.3 logs full error detail server-side but returns only a generic message for 500-class errors to the client, preventing stack traces, ORM query text, or file paths from leaking into API responses.
- Password policy edge cases —
createUserrejects short passwords with a clearValidationErrorrather than silently truncating or accepting weak credentials; pair this with a breached-password check (e.g., against the Have I Been Pwned k-anonymity API) for a meaningfully stronger control than length alone. - Rate limiter behind a proxy/load balancer —
express-rate-limitkeys byreq.ipby default, which is wrong behind a reverse proxy unlessapp.set("trust proxy", ...)is configured correctly; misconfiguring this either rate-limits your entire user base as one IP or, worse, disables rate limiting entirely because every request appears to come from a different (spoofed) address. - CORS allowlist maintenance — a hardcoded
ALLOWED_ORIGINSarray is fine for a handful of known frontends but becomes a deployment footgun for multi-tenant or preview-environment setups; move it to configuration and fail closed (deny) on any origin not explicitly present, never fail open. - npm audit false positives / transitive dev-only vulnerabilities — run
npm audit --production(as shown) to exclude devDependencies from the report, since a vulnerable devDependency (e.g., a build tool) is a materially different risk than a vulnerable runtime dependency shipped in production.