Skip to main content

How to secure an Express.js application?

Express.js security is a set of middleware layers that protect your API from common web attacks. Each layer stops a different class of threat, and all of them need to be in place together.

Theory

TL;DR

  • helmet sets 11 HTTP security headers automatically, including CSP, HSTS, and X-Frame-Options
  • Rate limiting blocks brute-force on auth routes (5 attempts per 15 min is a common threshold)
  • Input sanitization strips $where, $gt Mongo operators and HTML tags before they reach your logic
  • Secrets go in environment variables, never in source code
  • Middleware order matters: security layers go before your route handlers

Quick setup

js
const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const mongoSanitize = require('express-mongo-sanitize'); const xss = require('xss-clean'); const hpp = require('hpp'); // Security middleware runs before any routes app.use(helmet()); app.use(mongoSanitize()); // strips Mongo operator injection app.use(xss()); // removes HTML/script tags from body app.use(hpp()); // prevents duplicate query params app.use(express.json({ limit: '10kb' })); // caps request body size

One helmet() call replaces setting 11 headers by hand. The rest cover injection, XSS, parameter pollution, and payload size attacks.

HTTP headers with Helmet

A default Express app ships with X-Powered-By: Express in every response. That tells a scanner exactly what stack you're running. helmet() removes it and adds headers browsers actually enforce.

What helmet() sets by default:

  • Content-Security-Policy - controls where scripts and assets may load from
  • X-Frame-Options: DENY - blocks iframe-based clickjacking
  • Strict-Transport-Security - tells browsers to use HTTPS for the next year
  • X-Content-Type-Options: nosniff - prevents MIME-type sniffing attacks

No manual configuration needed for most apps. app.use(helmet()) is enough to start.

Rate limiting

Any endpoint that accepts credentials is a brute-force target without rate limiting. express-rate-limit tracks requests by IP and blocks after a threshold.

js
const rateLimit = require('express-rate-limit'); // General API limit const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15-minute window max: 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, try again later' } }); // Tighter limit for auth routes const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, // 5 attempts per 15 min skipSuccessfulRequests: true, // only failed attempts count message: { error: 'Too many login attempts' } }); app.use('/api/', apiLimiter); app.use('/auth/login', authLimiter); app.use('/auth/register', authLimiter);

skipSuccessfulRequests: true on auth routes means a real user logging in repeatedly doesn't get locked out. Only failed attempts count toward the limit.

Input sanitization and payload size

Two separate attack vectors come through the request body. NoSQL injection and XSS both exploit unsanitized input. Oversized payloads can exhaust memory and take your server down.

js
app.use(mongoSanitize()); // strips $where, $gt, $ne and similar operators app.use(xss()); // removes script/HTML tags from input fields app.use(express.json({ limit: '10kb' })); app.use(express.urlencoded({ limit: '10kb', extended: true }));

10kb is a reasonable default for most JSON APIs. File uploads go through a separate middleware like multer with its own size controls.

HTTPS enforcement

In production, all traffic should be encrypted. If Express sits behind nginx or a load balancer, TLS termination usually happens at the proxy level. If Express is exposed directly, add a redirect:

js
app.use((req, res, next) => { if (process.env.NODE_ENV === 'production' && !req.secure) { return res.redirect(301, `https://${req.hostname}${req.url}`); } next(); });

helmet() adds Strict-Transport-Security on top of this, which tells browsers to remember to use HTTPS even when a user types http:// directly.

Secrets in environment variables

js
// This ends up in git history. Someone will find it. const JWT_SECRET = 'hardcoded-secret-123'; // This stays on the server. const JWT_SECRET = process.env.JWT_SECRET;

Use dotenv locally. In production, inject environment variables through your deployment platform (Railway, Render, AWS ECS). Add .env to .gitignore immediately when you start the project, not after you've already committed a secret.

Parameter pollution prevention

A lesser-known attack: sending duplicate query parameters like /api/users?sort=name&sort=admin. Express and downstream middleware handle duplicate params inconsistently. Some take the first value, some take the last, some build an array. The hpp package normalizes this:

js
const hpp = require('hpp'); app.use(hpp()); // /api?sort=name&sort=price → req.query.sort becomes 'price'

Common mistakes

Wrong middleware order. If helmet() runs after your routes, it won't add headers to those responses. Security middleware goes at the top, before any route definition.

Huge body size limits. Setting limit: '100mb' on express.json() is a common copy-paste from tutorials that assumed file uploads. A 100mb JSON body can exhaust server RAM in seconds. Default to 10kb and increase only where you have a specific reason.

Missing skipSuccessfulRequests on login routes. Without it, a user who logs in 100 times successfully gets locked out the same as an attacker. Set it to true on auth endpoints.

Not failing fast on missing secrets. Running with JWT_SECRET = undefined means jsonwebtoken throws at the moment a user tries to log in, not at startup. Add a check:

js
if (!process.env.JWT_SECRET) { console.error('JWT_SECRET is not set'); process.exit(1); // crash loudly at boot, not at midnight }

Returning different errors for wrong email vs wrong password. This lets attackers enumerate which accounts exist. Always return the same generic message for both cases.

Security checklist

LayerPackageWhat it stops
HTTP headershelmetXSS, clickjacking, MIME sniffing
Rate limitingexpress-rate-limitBrute-force, credential stuffing
CORScorsUnauthorized cross-origin requests
NoSQL injectionexpress-mongo-sanitizeMongoDB operator injection
XSSxss-cleanScript injection via request body
Payload sizeexpress.json({ limit })Memory exhaustion
JWTjsonwebtokenSession forgery
Password hashingbcryptjs (12+ rounds)Credential leak damage
HTTPSnginx / redirect middlewareTraffic interception
Secretsenv vars + dotenvLeaked credentials in source
Param pollutionhppUnpredictable query parsing

Follow-up questions

Q: Does IP-based rate limiting work behind a load balancer?
A: Not by default. If all traffic arrives from the same load balancer IP, everyone shares one counter. Set trustProxy: true and read X-Forwarded-For for the real client IP. For multiple server instances, back the rate limiter with Redis so all instances share state.

Q: Is xss-clean enough to prevent XSS attacks?
A: No. xss-clean sanitizes the request body on the way in. XSS fires when you render unsanitized data in HTML on the way out. You also need output escaping on the frontend and a strict Content-Security-Policy header via helmet. Treat xss-clean as one layer, not the complete answer.

Q: When should you use bcrypt with more than 12 rounds?
A: Only when your server can handle the CPU cost. Benchmark on your actual hardware. A hash that takes 200-300ms per operation is a common target. Beyond that, legitimate login latency becomes noticeable to users.

Q: How do you handle rate limiting when many users share one IP (corporate NAT, university network)?
A: IP-only limiting will block everyone behind the same NAT after one bad actor hits the threshold. Layer it with account-level lockout (lock the account after 5 failed attempts regardless of IP) and add CAPTCHA on repeated failures from the same account.

Q: What does removing X-Powered-By actually accomplish?
A: On its own, not much. But it removes one signal that narrows down which CVEs to try against your server. Attackers scan millions of endpoints automatically. Giving them less information costs you nothing. helmet() handles this automatically.

Examples

Basic security stack for a new Express app

js
require('dotenv').config(); const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const mongoSanitize = require('express-mongo-sanitize'); const xss = require('xss-clean'); const hpp = require('hpp'); const app = express(); // Security first, before any route definitions app.use(helmet()); app.use(mongoSanitize()); app.use(xss()); app.use(hpp()); app.use(express.json({ limit: '10kb' })); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false }); app.use('/api', apiLimiter); app.get('/api/users', (req, res) => { res.json({ users: [] }); }); app.listen(process.env.PORT || 3000);

All security middleware runs before any route handler. Express processes middleware top-to-bottom, so the order is not optional.

Auth route with tighter rate limiting and password hashing

js
const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, skipSuccessfulRequests: true, message: { error: 'Too many login attempts, try again in 15 minutes' } }); app.post('/auth/login', authLimiter, async (req, res) => { const { email, password } = req.body; const user = await User.findOne({ email }); if (!user) { // Same message whether the email is wrong or the password is wrong return res.status(401).json({ error: 'Invalid credentials' }); } const match = await bcrypt.compare(password, user.passwordHash); if (!match) return res.status(401).json({ error: 'Invalid credentials' }); const token = jwt.sign( { userId: user._id }, process.env.JWT_SECRET, { expiresIn: '15m' } // short expiry; use refresh tokens for longer sessions ); res.json({ token }); });

Both wrong-email and wrong-password cases return the same generic error. That blocks user enumeration, where an attacker probes which email addresses have accounts registered.

Startup validation for missing secrets

js
// Check all required secrets at startup, not at request time const requiredEnv = ['JWT_SECRET', 'DB_PASSWORD', 'SESSION_SECRET']; for (const key of requiredEnv) { if (!process.env[key]) { console.error(`Missing required environment variable: ${key}`); process.exit(1); } } // Force HTTPS in production if (process.env.NODE_ENV === 'production') { app.use((req, res, next) => { if (!req.secure) { return res.redirect(301, `https://${req.hostname}${req.url}`); } next(); }); }

Crashing at startup on a missing secret is better than discovering it when a user tries to log in at midnight. I've seen apps run for weeks with JWT_SECRET = undefined because the error only appeared at the moment of token signing, not at boot.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?