Vulnerability Research
Express.js
Node.js
API Security
OWASP

7 Security Mistakes Every Express.js App Makes in Production

SecureCodeReviews Team
March 4, 2026
14 min read
Share

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

#MistakeImpactFix
1No security headersXSS, clickjacking, MIME sniffingHelmet.js
2No rate limitingBrute-force, DoSexpress-rate-limit
3No input validationMass assignment, prototype pollutionZod / Joi
4Stack traces in errorsInfo leakageCustom error handler
5Insecure sessions/JWTSession hijackingSecure flags + rotation
6Unrestricted uploadsRCE, DoSmulter limits + fileFilter
7NoSQL injectionAuth bypassType 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