Node.js Security

Protect a Node.js API from common attacks: injection, XSS, path traversal, brute force and oversized requests, with security headers, rate limiting, careful CORS, safe secrets handling, dependency audits and the principle of least privilege.

Lesson 20 of 43Advanced17 min read

Every server connected to the internet is probed by automated tools within minutes of going live. They try common passwords, send oversized requests, look for files such as .env, inject SQL into every form field, and check for outdated packages with known vulnerabilities. Security isn't a feature you add at the end; it's a set of habits applied everywhere.

The good news is that most real attacks exploit a small number of well-known weaknesses, catalogued in the OWASP Top 10: injection, broken authentication and access control, exposure of sensitive data, misconfiguration and vulnerable dependencies among them. Defending against them is mostly about doing simple things consistently: validate input, escape output, use parameterised queries, set safe defaults, keep secrets secret and keep packages updated.

This lesson brings together techniques from earlier lessons and adds new ones: security headers, CORS done properly, rate limiting, request limits, safe file handling, protecting secrets and auditing dependencies. Each example shows an attack and the fix, so you can recognise the patterns in your own code.

The main threats and defences

ThreatDefence
SQL or NoSQL injectionParameterised queries; validate types so objects can't sneak into queries
Cross-site scripting (XSS)Escape output; Content-Security-Policy header
Broken access controlCheck permissions on every request, server-side
Brute force and abuseRate limiting; account lockouts; CAPTCHAs on sign-up
Path traversalpath.basename on user file names; check resolved paths stay inside a folder
Command injectionNever pass user input to a shell; use execFile with argument arrays
Denial of serviceBody size limits, timeouts and pagination caps
Leaked secretsEnvironment variables, secret managers, never commit .env
Vulnerable packagesLock files, npm audit, automated updates

Headers, CORS and limits

  • helmet() sets a group of protective headers: Content-Security-Policy restricts where scripts can load from, X-Content-Type-Options: nosniff stops browsers guessing file types, Strict-Transport-Security forces HTTPS, and Express's X-Powered-By header is removed.
  • CORS decides which other websites' JavaScript may call your API from a browser. Allow a specific list of origins, such as cors({ origin: ['https://shop.example'] }), rather than *, especially when cookies are involved.
  • Limits: express.json({ limit: '100kb' }), a cap on page sizes, timeouts on outgoing requests, and rate limiting with a package like express-rate-limit, with stricter limits on sign-in and password-reset routes.

Secrets, dependencies and the environment

  • Keep secrets in environment variables or a secret manager, never in code or logs. Rotate any secret that leaks.
  • Commit package-lock.json, run npm audit in CI, and use Dependabot or Renovate to apply updates. Review new packages before adding them; each one runs with full access to your server.
  • Always serve production traffic over HTTPS; hosting platforms and load balancers set up TLS encryption for you.
  • Run Node as a non-root user with only the permissions it needs, the principle of least privilege.
  • Return generic error messages to clients, and keep personal data such as passwords and card numbers out of logs.

Node.js Advanced Syntax

Security setup

import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';

app.disable('x-powered-by');
app.use(helmet());
app.use(cors({ origin: ['https://shop.example'], credentials: true }));
app.use(express.json({ limit: '100kb' }));
app.use('/auth', rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 }));
const safeName = path.basename(req.params.file);
execFile('convert', [inputPath, outputPath]);   // no shell, no injection

Node.js Advanced Summary

  • Validate input, escape output and use parameterised queries to stop injection and XSS.
  • Use helmet for security headers and configure CORS with an explicit list of origins.
  • Rate-limit requests, cap body sizes and page sizes, and set timeouts.
  • Sanitise file names, never pass user input to a shell, and never use eval on input.
  • Keep secrets out of code, audit and update dependencies, use HTTPS and least privilege.

Node.js Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A simple rate limiter

Node.js
import express from 'express';

function rateLimit({ windowMs, limit }) {
  const hits = new Map();
  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
    recent.push(now);
    hits.set(key, recent);
    res.set('RateLimit-Remaining', String(Math.max(0, limit - recent.length)));
    if (recent.length > limit) {
      res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
      return res.status(429).json({ error: 'Too many attempts, try again later' });
    }
    next();
  };
}

const app = express();
app.post('/login', rateLimit({ windowMs: 60_000, limit: 3 }), (req, res) => res.status(401).json({ error: 'Wrong password' }));

const server = app.listen(0, async () => {
  const url = `http://localhost:${server.address().port}/login`;
  for (let i = 1; i <= 5; i++) {
    const r = await fetch(url, { method: 'POST' });
    console.log(`Attempt ${i}: ${r.status} remaining=${r.headers.get('ratelimit-remaining')}`);
  }
  server.close();
});
Output
Attempt 1: 401 remaining=2
Attempt 2: 401 remaining=1
Attempt 3: 401 remaining=0
Attempt 4: 429 remaining=0
Attempt 5: 429 remaining=0
Line-by-line explanation
  1. Line 4Each client's recent request times are kept in memory, keyed by IP address. In production, use express-rate-limit with a shared store such as Redis, so limits hold across several servers.
  2. Line 8Only requests inside the time window count, a sliding window.
  3. Lines 12 to 15Over the limit, the client gets 429 Too Many Requests and a Retry-After header.
  4. Line 21The limiter is applied to the sign-in route, where guessing passwords is the threat.
Example 2

Path traversal and safe file access

Node.js
import path from 'node:path';
import { mkdir, writeFile, readFile } from 'node:fs/promises';

const uploads = path.join(import.meta.dirname, 'uploads-demo');
await mkdir(uploads, { recursive: true });
await writeFile(path.join(uploads, 'invoice-17.pdf'), 'PDF data');
await writeFile(path.join(import.meta.dirname, 'secrets-demo.env'), 'API_KEY=abc123');

function unsafePath(name) {
  return path.join(uploads, name);
}

function safePath(name) {
  const full = path.resolve(uploads, name);
  if (!full.startsWith(uploads + path.sep)) throw new Error('Invalid file name');
  return full;
}

for (const name of ['invoice-17.pdf', '../secrets-demo.env']) {
  console.log(`unsafe ${name}: ${await readFile(unsafePath(name), 'utf8')}`);
  try {
    console.log(`safe ${name}: ${await readFile(safePath(name), 'utf8')}`);
  } catch (err) {
    console.log(`safe ${name}: ${err.message}`);
  }
}
Output
unsafe invoice-17.pdf: PDF data
safe invoice-17.pdf: PDF data
unsafe ../secrets-demo.env: API_KEY=abc123
safe ../secrets-demo.env: Invalid file name
Line-by-line explanation
  1. Lines 9 to 11Joining a user-supplied name directly lets ../ climb out of the uploads folder.
  2. Line 14resolve produces the final absolute path, with any ../ already applied.
  3. Line 15The path must still be inside the uploads folder, or the request is refused.
  4. Line 20The unsafe version leaks a secrets file to anyone who asks for ../secrets-demo.env.
Example 3

Security headers and CORS

Node.js
import express from 'express';

const allowed = new Set(['https://shop.example', 'https://admin.shop.example']);

const app = express();
app.disable('x-powered-by');
app.use((req, res, next) => {
  res.set({
    'Content-Security-Policy': "default-src 'self'",
    'X-Content-Type-Options': 'nosniff',
    'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
    'Referrer-Policy': 'no-referrer',
  });
  const origin = req.get('Origin');
  if (origin && allowed.has(origin)) {
    res.set({ 'Access-Control-Allow-Origin': origin, Vary: 'Origin' });
  }
  next();
});
app.get('/api/me', (req, res) => res.json({ user: 'ada' }));

const server = app.listen(0, async () => {
  const url = `http://localhost:${server.address().port}/api/me`;
  for (const origin of ['https://shop.example', 'https://evil.example']) {
    const r = await fetch(url, { headers: { Origin: origin } });
    console.log(origin, '->', r.headers.get('access-control-allow-origin') ?? 'blocked by the browser');
  }
  const r = await fetch(url);
  console.log(r.headers.get('x-powered-by') ?? 'no X-Powered-By header');
  console.log(r.headers.get('content-security-policy'));
  server.close();
});
Output
https://shop.example -> https://shop.example
https://evil.example -> blocked by the browser
no X-Powered-By header
default-src 'self'
Line-by-line explanation
  1. Line 6Removing X-Powered-By stops advertising which framework you run.
  2. Line 7In real projects, the helmet and cors packages set these headers with well-tested defaults.
  3. Line 9Content-Security-Policy only allows scripts and other resources from your own site, a strong defence against XSS.
  4. Line 11Strict-Transport-Security tells browsers to use HTTPS for a year.
  5. Lines 15 to 17Only origins on the allow list get a CORS header; browsers block other sites' scripts from reading the response.

Common Node.js Advanced Mistakes

eval on user input

Don't
const result = eval(req.query.formula);          // runs any code an attacker sends
Do
const result = calculate(parseFormula(req.query.formula));   // parse into a safe, limited format

User input in a shell command

Don't
exec(`convert ${req.body.file} out.png`);        // "a.jpg; rm -rf /" runs a second command
Do
execFile('convert', [safePath(req.body.file), 'out.png']);   // arguments are never parsed by a shell

CORS open to everyone with credentials

Don't
app.use(cors({ origin: true, credentials: true }));   // any website can make signed-in requests and read the replies
Do
app.use(cors({ origin: ['https://shop.example'], credentials: true }));

Node.js Advanced in Real Projects

Security in production Node apps

Mature teams run dependency scanning and secret scanning on every pull request, use helmet and a strict CORS list by default, place APIs behind a web application firewall or CDN with DDoS protection, and store secrets in services such as AWS Secrets Manager or Doppler. Penetration tests and bug bounty programmes find what automated tools miss, and incident response plans cover rotating keys quickly when something leaks.

Node.js Advanced FAQ

Is Express secure by default?

Express gives you the building blocks but very few protections out of the box: no security headers, no rate limiting, and it adds an X-Powered-By: Express header. Add helmet, set body size limits, configure CORS deliberately and validate every input. Keep Express and its dependencies up to date, since most fixes arrive as ordinary releases.

How do I protect an Express app against CSRF?

Cross-site request forgery tricks a signed-in user's browser into sending a request with their cookies. Setting session cookies with sameSite: 'lax' or 'strict' blocks most of it, and for forms that change data you can add a random CSRF token that the server checks on each submission. APIs that use an Authorization header instead of cookies aren't affected, because browsers don't attach that header automatically.

What is prototype pollution in JavaScript?

It happens when user input containing a key such as __proto__ is merged into an object by code that copies keys recursively, which modifies Object.prototype and so adds properties to every object in the program, such as an unexpected isAdmin: true. Avoid hand-written deep merges of untrusted data, validate input with a schema that strips unknown keys, and use Map or Object.create(null) for lookups keyed by user input. Node's --disable-proto=delete flag removes the __proto__ accessor entirely.

Why does rate limiting treat every user as the same IP behind a proxy?

Behind a load balancer or reverse proxy, every request reaches Node from the proxy's address, so req.ip is the same for everyone. Tell Express how many proxies to trust with app.set('trust proxy', 1); it then reads the client's address from the X-Forwarded-For header. Only trust as many hops as you actually have, or clients could fake their IP.

What is a supply-chain attack on npm packages?

An attacker publishes malicious code through a package you install, either by taking over a popular package, by publishing a look-alike name such as expres, or by using install scripts that run during npm install. Check package names carefully, keep dependencies few, commit your lock file and install with npm ci, and review what changed before accepting major updates.

Node.js Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the status code for too many requests.

res.status().json({ error: 'Slow down' });
Multiple choiceeasy

Which package sets a group of protective HTTP headers in Express?

Multiple choicemedium

What does a path traversal attack try to do?

Multiple choicemedium

Why use execFile with an argument array instead of exec with a string?

Multiple choicehard

Why is cors({ origin: true, credentials: true }) dangerous?

Node.js Advanced Quiz

5 questions to check what you learned.

  1. Where should API keys and database passwords live?

    • In the source code
    • In environment variables or a secret manager
    • In package.json
    • In public/config.js
  2. What does Content-Security-Policy help prevent?

    • SQL injection
    • Brute force attacks
    • Slow queries
    • Cross-site scripting, by limiting where scripts can load from
  3. Which routes most need strict rate limits?

    • Sign-in, sign-up and password reset
    • Static images
    • The home page
    • Health checks
  4. What does npm audit do?

    • Reports installed packages with known vulnerabilities
    • Speeds up installs
    • Removes unused packages
    • Encrypts node_modules
  5. What does the principle of least privilege mean for a Node server?

    • Use as few packages as possible
    • Run with only the permissions it needs, for example as a non-root user with a limited database account
    • Give every user admin rights
    • Disable logging