7 Security Mistakes Every Express.js App Makes in Production
On this page
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 →.
Published by SecureCodeReviews
This article is part of our original AI security and cybersecurity content library. We show publish and update dates, keep company and policy pages public, and update important guidance when material changes affect readers.
Shipping an API that needs a hard security pass?
We review authentication, authorization, business logic abuse, rate limiting, and the edge cases automated scanners usually miss.
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.