7 Security Mistakes Every Express.js App Makes in Production

SecureCodeReviews Team
March 4, 2026
14 min read
341 words
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 →.

Editorial standards

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.

Named author: SecureCodeReviews Team
Published: Mar 4, 2026
Update status: current publication version

Questions or corrections?

Review our editorial standards, learn more about the company, or contact us if a page needs clarification.

API Security Review

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.

OWASP API risk coverage and business logic testing
Auth and access control review with practical fixes
Clear technical findings your team can act on fast

Talk to SecureCodeReviews

Get a scoped review path fast

Manual review
Actionable fixes
Fast turnaround
Security-focused

Advertisement