Suggest an editImprove this articleRefine the answer for “How to secure an Express.js application?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Express.js security** is built from multiple middleware layers, each stopping a different class of attack. ```js app.use(helmet()); // sets 11 HTTP security headers app.use(rateLimit({ windowMs: 900000, max: 100 })); // blocks brute-force app.use(mongoSanitize()); // strips NoSQL injection operators app.use(express.json({ limit: '10kb' })); // prevents memory exhaustion ``` **Key point:** middleware order matters. Security layers go before your route handlers, and secrets go in environment variables, never in source code.Shown above the full answer for quick recall.Answer (EN)Image**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 | Layer | Package | What it stops | |---|---|---| | HTTP headers | `helmet` | XSS, clickjacking, MIME sniffing | | Rate limiting | `express-rate-limit` | Brute-force, credential stuffing | | CORS | `cors` | Unauthorized cross-origin requests | | NoSQL injection | `express-mongo-sanitize` | MongoDB operator injection | | XSS | `xss-clean` | Script injection via request body | | Payload size | `express.json({ limit })` | Memory exhaustion | | JWT | `jsonwebtoken` | Session forgery | | Password hashing | `bcryptjs` (12+ rounds) | Credential leak damage | | HTTPS | nginx / redirect middleware | Traffic interception | | Secrets | env vars + `dotenv` | Leaked credentials in source | | Param pollution | `hpp` | Unpredictable 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.