Security GuidesGuide

Secure Express.js

Express is the most widely deployed Node.js framework, which makes poorly configured Express APIs a recurring target. This guide covers the non-negotiable security controls every production Express application needs.

What we're covering

  1. Security headers with Helmet
  2. Rate limiting
  3. CORS configuration
  4. Input validation with Zod
  5. Authentication middleware patterns
  6. SQL / NoSQL injection prevention
  7. Error handling without leaking internals
  8. Dependency hygiene

1. Security headers with Helmet

Helmet sets security-relevant HTTP response headers. Install it as the first middleware in your chain.

npm install helmet
import express from 'express';
import helmet from 'helmet';
 
const app = express();
 
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // tighten if you can
        imgSrc: ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    crossOriginEmbedderPolicy: true,
    crossOriginOpenerPolicy: { policy: 'same-origin' },
    crossOriginResourcePolicy: { policy: 'same-site' },
    dnsPrefetchControl: { allow: false },
    frameguard: { action: 'deny' },
    hidePoweredBy: true,
    hsts: {
      maxAge: 63072000,        // 2 years
      includeSubDomains: true,
      preload: true,
    },
    ieNoOpen: true,
    noSniff: true,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    xssFilter: true,
  })
);

If your API serves only JSON (no HTML), you can disable contentSecurityPolicy entirely — it only matters for browsers rendering markup. But keep hsts, noSniff, and frameguard.


2. Rate limiting

Without rate limiting, your API is one for loop away from exhaustion. Use express-rate-limit as a baseline.

npm install express-rate-limit
import rateLimit from 'express-rate-limit';
 
// Global limiter — applies to all routes
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // max 100 requests per window per IP
  standardHeaders: true,     // Return rate limit info in `RateLimit-*` headers
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});
 
// Stricter limiter for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many authentication attempts.' },
  skipSuccessfulRequests: true, // only count failed requests
});
 
app.use(globalLimiter);
app.use('/api/auth', authLimiter);

Using Redis for distributed rate limiting

If you run multiple Node processes or containers, IP-based in-memory rate limiting won't work — each process keeps its own counter. Use rate-limit-redis:

npm install rate-limit-redis ioredis
import { createClient } from 'redis';
import { RedisStore } from 'rate-limit-redis';
 
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
 
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
});

If your app sits behind a load balancer or reverse proxy, Express may see the proxy's IP instead of the client's. Set app.set('trust proxy', 1) and configure your proxy to forward X-Forwarded-For correctly. Without this, every request appears to come from the same IP and the limiter becomes useless.


3. CORS configuration

The wrong CORS config is worse than no CORS config — a wildcard * on an authenticated API disables the browser's origin check entirely.

npm install cors
import cors from 'cors';
 
const allowedOrigins = process.env.NODE_ENV === 'production'
  ? ['https://yourapp.com', 'https://www.yourapp.com']
  : ['http://localhost:3000', 'http://localhost:5173'];
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Allow requests with no Origin header (server-to-server, curl)
      if (!origin) return callback(null, true);
 
      if (allowedOrigins.includes(origin)) {
        callback(null, true);
      } else {
        callback(new Error(`CORS: origin ${origin} not allowed`));
      }
    },
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    credentials: true,      // Required if you send cookies or Authorization headers
    maxAge: 86400,          // Cache preflight for 24 hours
  })
);

For a public read-only API (no cookies, no user-specific data), origin: '*' is fine. The issue is combining * with credentials: true — browsers reject this combination, and some middleware silently falls back to an insecure state.


4. Input validation with Zod

Never trust request bodies. Parse and validate them before touching your database.

npm install zod
import { z } from 'zod';
 
const CreateUserSchema = z.object({
  email: z.string().email().max(254).toLowerCase().trim(),
  password: z.string().min(12).max(128),
  name: z.string().min(1).max(100).trim(),
  role: z.enum(['user', 'admin']).default('user'),
});
 
// Validation middleware factory
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        issues: result.error.issues.map((i) => ({
          field: i.path.join('.'),
          message: i.message,
        })),
      });
    }
    req.body = result.data; // Replace raw body with parsed+coerced data
    next();
  };
}
 
app.post('/api/users', validate(CreateUserSchema), async (req, res) => {
  const { email, password, name, role } = req.body; // Fully validated
  // ...
});

Validating query params and route params too

const GetUserParamsSchema = z.object({
  id: z.string().uuid(),
});
 
const GetUsersQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(['asc', 'desc']).default('asc'),
});
 
function validateParams(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.params);
    if (!result.success) return res.status(400).json({ error: 'Invalid parameters' });
    req.params = result.data;
    next();
  };
}
 
function validateQuery(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.query);
    if (!result.success) return res.status(400).json({ error: 'Invalid query parameters' });
    req.query = result.data;
    next();
  };
}
 
app.get(
  '/api/users/:id',
  validateParams(GetUserParamsSchema),
  validateQuery(GetUsersQuerySchema),
  async (req, res) => { /* ... */ }
);

5. Authentication middleware

JWT verification

npm install jsonwebtoken
import jwt from 'jsonwebtoken';
 
const JWT_SECRET = process.env.JWT_SECRET; // min 32 chars, from secrets manager
 
function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
 
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid Authorization header' });
  }
 
  const token = authHeader.slice(7);
 
  try {
    const payload = jwt.verify(token, JWT_SECRET, {
      algorithms: ['HS256'],   // Explicitly allowlist — prevents algorithm confusion
      issuer: 'yourapp.com',
      audience: 'yourapp-api',
    });
    req.user = payload;
    next();
  } catch (err) {
    const message =
      err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
    return res.status(401).json({ error: message });
  }
}
 
// Role-based access control
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user?.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}
 
app.get('/api/admin/users', requireAuth, requireRole('admin'), async (req, res) => {
  // ...
});

Always specify algorithms in jwt.verify. The default allows the none algorithm, which lets an attacker forge tokens with no signature. This is CVE-2015-9235 and variants of it still appear in audits.

For server-rendered apps or when you control both client and server:

npm install express-session connect-redis
import session from 'express-session';
import { RedisStore } from 'connect-redis';
 
app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,   // min 32 chars
    resave: false,
    saveUninitialized: false,
    name: '__Host-sid',                   // __Host- prefix enforces Secure + no Domain
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days
    },
  })
);

6. Injection prevention

SQL injection with parameterized queries

Never concatenate user input into SQL strings.

// Bad
const user = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
 
// Good (pg)
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
 
// Good (mysql2)
const [rows] = await db.execute('SELECT * FROM users WHERE email = ?', [email]);
 
// Good (Prisma — parameterizes automatically)
const user = await prisma.user.findUnique({ where: { email } });

NoSQL injection (MongoDB)

MongoDB operators like $where, $gt, $ne can be injected through request bodies:

// Vulnerable — attacker sends { "password": { "$gt": "" } }
const user = await User.findOne({ email: req.body.email, password: req.body.password });
 
// Safe — use Zod to ensure password is always a string
const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});
 
// Also sanitize query objects
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize()); // Strips $ and . from req.body, req.query, req.params

7. Error handling without leaking internals

Express's default error output includes stack traces in development but you need to make sure they never reach production clients.

// Global error handler — must be defined LAST, after all routes
app.use((err, req, res, next) => {
  // Log the full error internally
  console.error({
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    ip: req.ip,
    userId: req.user?.id,
  });
 
  // Never expose stack traces or internal messages to clients
  const statusCode = err.statusCode ?? err.status ?? 500;
 
  res.status(statusCode).json({
    error:
      statusCode < 500
        ? err.message                  // 4xx: client errors are safe to describe
        : 'An unexpected error occurred', // 5xx: hide internals
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
});
 
// Catch async errors without try/catch in every handler (Express 5)
// In Express 4, wrap handlers with express-async-errors or a utility:
import 'express-async-errors'; // npm install express-async-errors

In Express 5 (now stable), async route handlers that throw are automatically forwarded to the error handler. In Express 4, unhandled promise rejections crash the process or hang — use express-async-errors or explicit .catch(next).


8. Dependency hygiene

# Audit for known vulnerabilities
npm audit
 
# Fix automatically where possible
npm audit fix
 
# Check for outdated packages
npm outdated
 
# Only install production dependencies in deployed containers
npm ci --omit=dev

Add to your CI pipeline:

# GitHub Actions example
- name: Security audit
  run: npm audit --audit-level=high

npm audit only catches vulnerabilities in packages that are in the npm advisory database. It won't catch unmaintained packages, typosquatted packages, or supply chain attacks. Run npx socket or integrate with a software composition analysis (SCA) tool for deeper coverage.


Quick reference

ControlPackageWhere to apply
Security headershelmetFirst middleware
Rate limitingexpress-rate-limitGlobal + per-route for auth
CORScorsBefore routes
Body parsingexpress.json({ limit: '10kb' })Before validation
Input validationzodPer-route middleware
AuthjsonwebtokenProtected route middleware
SQL injectionParameterized queriesAt every DB query
NoSQL injectionexpress-mongo-sanitizeAfter body parser
Error handlingCustom error handlerLast middleware

What PatchVex checks

The PatchVex Web Scanner checks for missing security headers (X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Content-Security-Policy) on any public URL, including Express API endpoints that serve HTML or mixed content.

Terminal

$ vulnpilot scan --target https://api.yourapp.com --checks headers,cors Scanning https://api.yourapp.com...

✓ Strict-Transport-Security max-age=63072000; includeSubDomains ✗ Content-Security-Policy Header missing ✗ X-Frame-Options Header missing ✓ X-Content-Type-Options nosniff

2 issues found. Run vulnpilot fix to see recommended configuration.