Security Best Practices
A server on the internet is probed by automated bots within minutes of going live. They try common passwords, send broken input, look for known bugs in old packages and search for leaked secrets. Security is not one feature you add at the end; it is a set of habits applied everywhere in your code.
This lesson collects the most important habits for Node.js and Express apps, many of which you have already met in earlier lessons, and adds new tools: npm audit, rate limiting, safe child processes and Node.js's built-in permission model. The list follows the spirit of the OWASP Top 10, a widely used list of the most serious web application risks.
1. Keep Node.js and packages up to date
Most real attacks use known vulnerabilities in old software. Run a supported LTS Node.js (26 or 24 in late 2026), apply its security releases, and check your dependencies:
npm audit # list known vulnerabilities in installed packages
npm audit fix # update to fixed versions within your allowed ranges
npm outdated # see which packages have newer versionsnpm audit compares your lock file with a database of reported problems. Also:
- Commit
package-lock.jsonand install withnpm cion servers, so you deploy exactly the versions you tested. - Before adding a package, check that it is maintained and widely used, and that the name is spelled correctly.
- Remove packages you no longer use. Every dependency is code you trust with your server.
- Turn on automatic update pull requests (for example GitHub's Dependabot) so you hear about fixes quickly.
2. Keep secrets out of code
Database passwords, JWT secrets and API keys belong in environment variables, as shown in process & Environment Variables. Never commit .env, never log secrets, and use different secrets for development and production. If a secret ever reaches GitHub, treat it as stolen: change it immediately, even if you delete the commit.
3. Secure HTTP headers and CORS
The helmet middleware sets many protective response headers in one line, including Content-Security-Policy (which limits where scripts may load from), Strict-Transport-Security (which tells browsers to use HTTPS only) and X-Content-Type-Options: nosniff. It also removes X-Powered-By: Express.
import express from "express";
import helmet from "helmet";
import cors from "cors";
const app = express();
app.use(helmet());
app.use(cors({ origin: ["https://shop.example.in", "https://admin.example.in"] }));
app.use(express.json({ limit: "100kb" }));CORS decides which other websites may call your API from a browser. Allow only the front ends you own. cors() with no options allows every website, which is fine for a truly public, read-only API but not for one that uses cookies or changes data. The limit on express.json() stops huge request bodies.
4. Limit repeated requests
Without limits, an attacker can try thousands of passwords per minute or flood an expensive route. The express-rate-limit package counts requests per client IP address:
import express from "express";
import { rateLimit } from "express-rate-limit";
const app = express();
app.use(express.json());
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 5, // 5 attempts per IP in that window
standardHeaders: "draft-8",
legacyHeaders: false,
message: { error: "Too many login attempts. Try again in 15 minutes." },
});
app.post("/api/login", loginLimiter, (req, res) => {
res.status(401).json({ error: "Wrong email or password" });
});
app.listen(3000);Seven wrong guesses in a row produce:
1 401 Wrong email or password | RateLimit: "5-in-15min"; r=4; t=900 2 401 Wrong email or password | RateLimit: "5-in-15min"; r=3; t=900 3 401 Wrong email or password | RateLimit: "5-in-15min"; r=2; t=900 4 401 Wrong email or password | RateLimit: "5-in-15min"; r=1; t=900 5 401 Wrong email or password | RateLimit: "5-in-15min"; r=0; t=900 6 429 Too many login attempts. Try again in 15 minutes. | RateLimit: "5-in-15min"; r=0; t=900 7 429 Too many login attempts. Try again in 15 minutes. | RateLimit: "5-in-15min"; r=0; t=900
Status 429 Too Many Requests tells the client to slow down, and the RateLimit header shows the remaining requests (r) and seconds until the window resets (t). Use strict limits on login, sign-up, password reset and OTP routes, and a looser one for the whole API. Behind a reverse proxy, set app.set("trust proxy", 1) so the limiter sees the real client IP (see Deployment).
5. Never trust input: injection
Injection happens when user input is treated as code or as part of a command. You have met three kinds already:
- SQL injection: always use placeholders (
pool.execute(sql, [values])), never template strings. See Connecting to MySQL. - NoSQL injection: validate that values are strings or numbers before putting them in a MongoDB filter. See Connecting to MongoDB.
- Path traversal: resolve paths and check they stay inside the allowed folder. See path & URL Modules.
A fourth kind is command injection. child_process.exec() runs a whole string through the system shell, so input containing &, ; or | can start extra commands. Use execFile() or spawn() with an array of arguments, which never goes through a shell:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
// User input that tries to sneak in a second command
const fileName = "report.txt & echo HACKED";
// SAFE: arguments are passed as an array, never through a shell
const { stdout } = await run("node", ["-e", "console.log('Checking: ' + process.argv[1])", fileName]);
console.log(stdout.trim());Checking: report.txt & echo HACKED
The whole input arrived as one harmless argument; no second command ran. Similarly, never pass user input to eval(), new Function() or setTimeout with a string.
Validation with a schema (see Validating Input) is your first line of defence for all of these: check type, length and format, and drop unknown fields to prevent mass assignment.
6. Cross-site scripting (XSS)
XSS happens when your site shows user-supplied text as HTML, so an attacker's <script> runs in other users' browsers and can steal their data. Defences:
- In browser code, insert user text with
textContent, notinnerHTML. - In server-rendered HTML, use a template engine that escapes output by default (EJS
<%= %>, Pug, Handlebars{{ }}). - Keep helmet's
Content-Security-Policy, which blocks inline scripts that an attacker might inject. - Mark session and token cookies
httpOnlyso scripts cannot read them.
7. Authentication and sessions
From the earlier lessons: hash passwords with Argon2id or bcrypt; use httpOnly, secure and sameSite cookies; regenerate the session at login; give the same error for unknown emails and wrong passwords; keep JWTs short-lived and always verify them with a fixed algorithm. Also check authorisation on every request: being logged in as Rohan must not let you read /api/orders/105 if order 105 belongs to Priya. This "broken access control" is the top item of the OWASP Top 10.
8. Limit what the process can do
Run Node.js as a normal user, never as root or Administrator, so a successful attack cannot take over the whole machine. Node.js also has a built-in permission model: start it with --permission and the process can only read and write the paths you allow, and cannot start child processes or worker threads unless you allow them too.
// reader.js
import { readFile, writeFile } from "node:fs/promises";
console.log((await readFile("data/cities.txt", "utf8")).trim());
console.log(process.permission.has("fs.write", "data"));
try {
await writeFile("data/hacked.txt", "oops");
} catch (err) {
console.log(err.code, err.permission);
}
try {
await readFile("C:/Windows/win.ini", "utf8");
} catch (err) {
console.log(err.code, err.permission);
}node --permission --allow-fs-read=. reader.jsRohtak,Jaipur,Pune false ERR_ACCESS_DENIED FileSystemWrite ERR_ACCESS_DENIED FileSystemRead
The script could read files in its own folder but could not write anywhere or read system files. Add --allow-fs-write=./uploads to allow writing to one folder, and --allow-child-process or --allow-worker only if the app really needs them. The permission model became stable in Node.js 23.5 and 22.13. It is an extra safety net that limits the damage of a bug or a malicious dependency; it does not replace the other practices here.
Security checklist
| Area | Do this |
|---|---|
| Updates | LTS Node.js, npm audit, npm ci, few and trusted packages |
| Secrets | Environment variables, .env in .gitignore, rotate leaked secrets |
| Transport | HTTPS everywhere, secure cookies, HSTS via helmet |
| Input | Schema validation, size limits, placeholders, execFile with arrays |
| Output | Escape HTML, textContent, Content-Security-Policy, no stack traces to users |
| Access | Hash passwords, rate-limit logins, check ownership on every request |
| Runtime | Non-root user, --permission where practical, log security events |
Common mistakes
- Running old dependencies for years. Schedule regular updates and audits.
- Allowing every CORS origin on an API that uses cookies or changes data.
- No rate limit on login or OTP routes.
- Checking only that a user is logged in, not that they own the record they are changing.
- Using
exec()with user input. UseexecFile()orspawn()with an argument array.
Key points
- Use a supported LTS Node.js, run
npm audit, deploy withnpm ci, and keep dependencies few and current. - Keep secrets in environment variables; add helmet, a strict CORS list and body size limits.
- Rate-limit sensitive routes;
429tells clients they sent too many requests. - Prevent injection with validation, placeholders, safe paths and
execFile()/spawn()with argument arrays. - Escape output and use CSP against XSS; check authorisation on every request.
- Run as a non-root user and consider
node --permissionto restrict file and process access.
Exercise
Review this route from a college admin panel and list at least four security problems. Then rewrite it safely, assuming pool (mysql2), requireAuth and requireRole from earlier lessons are available.
app.get("/admin/student", async (req, res) => {
const [rows] = await pool.query(`SELECT * FROM students WHERE roll = '${req.query.roll}'`);
if (!rows.length) return res.send(`<h1>No student ${req.query.roll}</h1>`);
res.json(rows[0]);
});Show solution
Problems: no login or role check, so anyone can read student data; SQL injection through the template string; the "not found" page puts user input into HTML (XSS); SELECT * may return sensitive columns such as password hashes; and the roll number is never validated.
app.get("/admin/student", requireAuth, requireRole("admin"), async (req, res) => {
const roll = String(req.query.roll ?? "");
if (!/^[0-9]{1,8}$/.test(roll)) {
return res.status(400).json({ error: "Roll number must be 1 to 8 digits" });
}
const [rows] = await pool.execute(
"SELECT roll, name, course, city FROM students WHERE roll = ?",
[roll],
);
if (rows.length === 0) return res.status(404).json({ error: "Student not found" });
res.json(rows[0]);
});GET /admin/student?roll=1' OR '1'='1 -> 400 {"error":"Roll number must be 1 to 8 digits"}The route now checks the user and role first, validates the input, uses a placeholder, selects only the needed columns and answers in JSON, so no user text is ever placed into HTML.
Quick Quiz
1. What does npm audit do?
npm audit reports known security problems and the versions that fix them.
2. Which status code does a rate limiter send when a client exceeds the limit?
429 Too Many Requests tells the client to slow down.
3. Why is execFile("convert", [userFile, "out.png"]) safer than exec("convert " + userFile + " out.png")?
Avoiding the shell removes the risk of command injection.
4. What does starting Node.js with --permission --allow-fs-read=. do?
The permission model restricts what the Node.js process itself can access.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
