7 Security Mistakes Every Express.js App Makes in Production
Introduction
Express.js powers over 60% of Node.js web applications. Its minimalist design is its strength — and its biggest security risk. Unlike Django or Rails, Express ships with zero security defaults. Every protection must be explicitly added.
Here are the 7 most common security mistakes we find in production Express apps during our code reviews.
Mistake #1: Missing Security Headers (No Helmet.js)
Out of the box, Express sends no security headers. No HSTS, no CSP, no X-Frame-Options.
❌ The Problem
const express = require('express');
const app = express();
// No security headers at all
app.get('/', (req, res) => {
res.send('Hello World');
});
✅ The Fix
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet()); // Adds 11 security headers
// For custom CSP:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}));
Test your headers: Use our free Security Header Scanner to check your site.
Mistake #2: No Rate Limiting
Without rate limiting, your API is vulnerable to brute-force attacks, credential stuffing, and DoS.
❌ The Problem
// Login endpoint with no rate limiting
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET);
res.json({ token });
});
An attacker can try millions of password combinations with no throttling.
✅ The Fix
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
skipSuccessfulRequests: true,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true, // RateLimit-* headers
});
app.post('/api/login', loginLimiter, async (req, res) => {
// ... login logic
});
Mistake #3: Trusting req.body Without Validation
Express with express.json() parses any valid JSON — including unexpected types, nested objects, and prototype pollution payloads.
❌ The Problem
app.post('/api/users', async (req, res) => {
// Directly using req.body — no validation
const user = new User(req.body); // What if req.body includes { role: "admin" }?
await user.save();
res.json(user);
});
Attacks: Mass assignment, type confusion, prototype pollution via __proto__.
✅ The Fix
const { z } = require('zod');
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
});
app.post('/api/users', async (req, res) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// Only validated fields are used
const user = new User(result.data);
await user.save();
res.json(user);
});
Mistake #4: Information Leakage in Error Responses
Express's default error handler sends stack traces to clients in development. Many apps accidentally leave this in production.
❌ The Problem
app.get('/api/data', async (req, res) => {
const data = await db.query('SELECT * FROM secret_table');
res.json(data);
});
// Default Express error handler:
// Error: connect ECONNREFUSED 10.0.1.42:5432
// at TCPConnectWrap.afterConnect [as oncomplete]
// Exposes: internal IP, database type, server structure
✅ The Fix
// Global error handler — always last middleware
app.use((err, req, res, next) => {
console.error(err); // Log the real error server-side
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
});
// Also remove X-Powered-By
app.disable('x-powered-by');
Mistake #5: Insecure Session / JWT Configuration
❌ The Problem
// Cookie without secure flags
app.use(session({
secret: 'keyboard-cat', // Weak secret
cookie: {
// Missing: secure, httpOnly, sameSite, maxAge
}
}));
// JWT with no expiration
const token = jwt.sign({ id: user._id }, 'secret123');
✅ The Fix
app.use(session({
secret: process.env.SESSION_SECRET, // Strong, env-based secret
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JS access
sameSite: 'strict', // CSRF protection
maxAge: 3600000, // 1 hour
}
}));
// JWT with expiration and strong secret
const token = jwt.sign(
{ id: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h', algorithm: 'HS256' }
);
Mistake #6: Unrestricted File Uploads
❌ The Problem
const multer = require('multer');
const upload = multer({ dest: 'uploads/' }); // No restrictions!
app.post('/api/upload', upload.single('file'), (req, res) => {
res.json({ path: req.file.path });
});
An attacker can upload a 10GB file, a .exe, or a webshell.
✅ The Fix
const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('File type not allowed'), false);
}
cb(null, true);
},
});
Mistake #7: NoSQL Injection in MongoDB Queries
❌ The Problem
app.post('/api/login', async (req, res) => {
const user = await User.findOne({
email: req.body.email,
password: req.body.password, // NoSQL injection!
});
// Attack: { "email": "admin@site.com", "password": { "$ne": "" } }
// This matches ANY non-empty password → bypass authentication
});
✅ The Fix
app.post('/api/login', async (req, res) => {
// Type check — ensure strings only
if (typeof req.body.email !== 'string' || typeof req.body.password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const user = await User.findOne({ email: req.body.email });
if (!user || !await bcrypt.compare(req.body.password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// ...
});
Summary
| # | Mistake | Impact | Fix |
|---|---|---|---|
| 1 | No security headers | XSS, clickjacking, MIME sniffing | Helmet.js |
| 2 | No rate limiting | Brute-force, DoS | express-rate-limit |
| 3 | No input validation | Mass assignment, prototype pollution | Zod / Joi |
| 4 | Stack traces in errors | Info leakage | Custom error handler |
| 5 | Insecure sessions/JWT | Session hijacking | Secure flags + rotation |
| 6 | Unrestricted uploads | RCE, DoS | multer limits + fileFilter |
| 7 | NoSQL injection | Auth bypass | Type checking + bcrypt |
Every one of these mistakes is something we find in real production apps. If you're running Express.js in production, request a free security check →.
Advertisement
Free Security Tools
Try our tools now
Expert Services
Get professional help
OWASP Top 10
Learn the top risks
Related Articles
OWASP Top 10 2025: What's Changed and How to Prepare
A comprehensive breakdown of the latest OWASP Top 10 vulnerabilities and actionable steps to secure your applications against them.
The Ultimate Secure Code Review Checklist for 2025
A comprehensive, actionable checklist for conducting secure code reviews. Covers input validation, authentication, authorization, cryptography, error handling, and CI/CD integration with real-world examples.
JWT Security: Vulnerabilities, Best Practices & Implementation Guide
Comprehensive JWT security guide covering token anatomy, common vulnerabilities, RS256 vs HS256, refresh tokens, and secure implementation patterns.