Node.js and Express course menu
Express API Security Best Practices (Helmet, CORS, Rate Limiting)
Secure a Node.js and Express API: security headers with Helmet, CORS for browser clients, rate limiting to stop brute-force attacks, validation and SQL injection, secrets in environment variables, HTTPS, hiding error details, and keeping dependencies patched with npm audit.
On this page
Every public API is probed by automated attacks within hours of going online. Security is not one feature but a set of habits. This lesson covers the most important ones for Express apps, many of which appear in the OWASP Top 10 list of common web application risks.
Security headers with Helmet#
HTTP response headers can tell browsers to block risky behaviour: loading your pages in frames on other sites (clickjacking), guessing content types, or loading scripts from unexpected places. Helmet sets a sensible collection of these headers:
npm install helmetimport express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet());
app.get("/api/status", (req, res) => res.json({ ok: true }));
app.listen(3000);curl -s -i localhost:3000/api/status | grep -E "^(X-Frame-Options|X-Content-Type-Options|Strict-Transport-Security|Content-Security-Policy|X-Powered-By|Referrer-Policy)"Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGINAmong other things, Helmet removes the X-Powered-By: Express header (no need to advertise your framework), forbids framing, stops MIME-type sniffing, and adds a Content Security Policy. Without Helmet, app.disable("x-powered-by") removes that one header.
CORS: which websites may call your API#
Browsers block JavaScript on one origin (say https://shop.example.com) from reading responses from another origin (https://api.example.com) unless the API allows it with CORS (Cross-Origin Resource Sharing) headers. CORS only affects browsers; curl and servers are not restricted by it.
npm install corsimport cors from "cors";
import express from "express";
const app = express();
app.use(cors({ origin: ["https://shop.example.com"], methods: ["GET", "POST"] }));
app.get("/api/products", (req, res) => res.json([{ id: 1, name: "Notebook" }]));
app.listen(3000);curl -s -i -H "Origin: https://shop.example.com" localhost:3000/api/products | grep -i "^access-control-allow-origin"
curl -s -i -H "Origin: https://evil.example.net" localhost:3000/api/products | grep -ci "^access-control-allow-origin"
curl -s -i -X OPTIONS -H "Origin: https://shop.example.com" -H "Access-Control-Request-Method: POST" localhost:3000/api/products | grep -iE "^(HTTP|access-control-allow-(origin|methods))"Access-Control-Allow-Origin: https://shop.example.com
0
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET,POSTThe allowed origin gets an Access-Control-Allow-Origin header; the other origin gets none (grep -c counted 0), so a browser would block its script from reading the response. The OPTIONS request is the preflight that browsers send before non-simple requests. Avoid cors() with no options (it allows every origin) on APIs that use cookies or serve private data.
Rate limiting#
Without limits, attackers can try thousands of passwords per minute or flood expensive endpoints. A rate limiter caps requests per client per time window. A simple in-memory version shows the idea:
import express from "express";
function rateLimit({ windowMs, max }) {
const hits = new Map(); // ip → { count, resetAt }
return (req, res, next) => {
const now = Date.now();
const entry = hits.get(req.ip);
if (!entry || now > entry.resetAt) {
hits.set(req.ip, { count: 1, resetAt: now + windowMs });
return next();
}
entry.count += 1;
if (entry.count > max) {
res.set("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
return res.status(429).json({ error: "Too many attempts. Try again later." });
}
next();
};
}
const app = express();
app.use(express.json());
app.post("/api/login", rateLimit({ windowMs: 60_000, max: 3 }), (req, res) => {
res.status(401).json({ error: "Invalid email or password" });
});
app.listen(3000);curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/login
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/login
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/login
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/login
curl -s -X POST localhost:3000/api/login401
401
401
429
{"error":"Too many attempts. Try again later."}After three attempts in a minute, the same client gets 429 Too Many Requests. In production, use a maintained package such as express-rate-limit, and store counts in Redis when you run several server instances, since in-memory counts are per process. Behind a proxy or load balancer, configure app.set("trust proxy", ...) so req.ip is the real client address.
The checklist#
Input and data
- Validate every input (Validation) and limit body sizes.
- Use parameterised queries to prevent SQL injection.
- Escape user content in HTML (template engines and React do this by default) to prevent cross-site scripting (XSS).
- Check authorisation on every request: "can this user see order 42?" (broken access control is the top OWASP risk).
Secrets and configuration
- Keep secrets in environment variables, never in code or Git; rotate any secret that leaks.
- Different secrets and databases for development, testing and production.
Transport and errors
- Serve everything over HTTPS (hosting platforms provide certificates); set cookies with
Secure,HttpOnlyandSameSite. - Return generic messages for 500 errors and log the details on the server.
Dependencies
- Run
npm auditregularly and update vulnerable packages; enable automated alerts (such as Dependabot). - Prefer well-maintained packages with many users; review what a new dependency does.
Authentication
- Hash passwords with scrypt, bcrypt or Argon2; rate-limit login and password-reset endpoints.
- Short-lived tokens, and the same error message for unknown users and wrong passwords.
Common mistakes#
Practice#
Practice exercise
A route GET /api/orders/:id returns any order by id to any logged-in user. Explain the vulnerability and write the fix, assuming req.user.sub holds the logged-in user's id and each order has a userId.
Show hint
This is broken access control (also called IDOR: insecure direct object reference). Check ownership before returning the order.
Show solution
Any logged-in user can read other customers' orders just by changing the number in the URL. The server must check that the order belongs to the requester (or that the requester is an admin):
app.get("/api/orders/:id", requireAuth, (req, res) => {
const order = findOrder(Number(req.params.id));
if (!order || (order.userId !== Number(req.user.sub) && req.user.role !== "admin")) {
return res.status(404).json({ error: "Order not found" });
}
res.json(order);
});Returning 404 rather than 403 for other people's orders avoids confirming that the order exists.
Key takeaway#
Next: Testing Node.js APIs.
Finished this lesson?