Harden a Node API in production: correct CORS, rate limiting, security headers, and defenses against the main OWASP API risks.
Open this lesson in KodokonOWASP maintains a top 10 dedicated to APIs. The three dominant risks: BOLA (Broken Object Level Authorization - accessing another user's object by changing an id), Broken Authentication (poorly validated tokens, weak secrets), and Unrestricted Resource Consumption (no limit on rate, size, or time). Notice that none of the three is fixed by installing a library: they are properties of your design. Let's start with the cross-cutting mechanisms: CORS, rate limiting, and headers.
import { createServer } from "node:http";
const allowed = new Set(["https://app.example.com"]);
const server = createServer((req, res) => {
const origin = req.headers.origin;
if (origin && allowed.has(origin)) {
res.setHeader(
"Access-Control-Allow-Origin",
origin,
);
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET,POST,PUT,DELETE",
);
res.setHeader(
"Access-Control-Allow-Headers",
"content-type,authorization",
);
res.setHeader("Access-Control-Max-Age", "86400");
res.writeHead(204).end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true }));
});
server.listen(3000);Understand clearly what CORS is not: server protection. curl or a third-party backend completely ignore these headers; only the browser enforces them, to protect its users. Three expert rules: return the exact origin from an allowlist (never blindly echo the Origin header), add Vary: Origin so you don't poison intermediate caches, and know that the * wildcard is rejected by browsers as soon as the request carries cookies (credentials).
const WINDOW_MS = 60_000;
const LIMIT = 100;
const hits = new Map();
function check(ip) {
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now - entry.start >= WINDOW_MS) {
hits.set(ip, { start: now, count: 1 });
return { ok: true, remaining: LIMIT - 1 };
}
entry.count += 1;
const remaining = Math.max(LIMIT - entry.count, 0);
return { ok: entry.count <= LIMIT, remaining };
}
console.log(check("203.0.113.7"));Security headers cost a few lines and block entire classes of attacks: HSTS forces HTTPS for future visits, X-Content-Type-Options: nosniff prevents the browser from guessing a MIME type, and a restrictive CSP (default-src 'none') neutralizes the exploitation of an API response mistakenly rendered in a browser. Also remove any header that reveals your stack: Express adds X-Powered-By by default - disable it with app.disable('x-powered-by').
function setSecurityHeaders(res) {
res.setHeader(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains",
);
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'",
);
res.setHeader("Cache-Control", "no-store");
}
export { setSecurityHeaders };