Express.js Security Guide: Helmet, Rate Limiting, and Input Validation
Secure Express.js applications with Helmet middleware, express-rate-limit, Joi/Zod input validation, SQL injection prevention, and body-parser limits.
Express.js Security Guide: Helmet, Rate Limiting, and Input Validation
Express.js is deliberately minimal — it ships with almost no security features out of the box. That flexibility is its strength, but it means you have to consciously add protections that other frameworks provide automatically. This guide covers every essential layer of Express security.
Helmet: Security Headers in One Package
Helmet is a collection of middleware functions that set HTTP security headers. Install it and apply it as early as possible in your middleware chain:
npm install helmet
import express from 'express';
import helmet from 'helmet';
const app = express();
// Apply Helmet before any routes
app.use(helmet());
By default, Helmet sets:
Content-Security-Policy(restrictive default)X-DNS-Prefetch-Control: offX-Frame-Options: SAMEORIGINX-Content-Type-Options: nosniffReferrer-Policy: no-referrerStrict-Transport-Security(HSTS)X-XSS-Protection: 0(disables legacy XSS filter — correct behavior)
Customize CSP for your application:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-GENERATED_NONCE'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: 'deny' },
})
);
Body Parser Limits
By default, Express has no limit on request body size. An attacker can send a 1 GB JSON payload and exhaust your server's memory:
// Limit JSON bodies to 100kb
app.use(express.json({ limit: '100kb' }));
// Limit URL-encoded bodies
app.use(express.urlencoded({ extended: true, limit: '100kb' }));
// For file uploads, use multer with explicit file size limits
import multer from 'multer';
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
cb(null, allowed.includes(file.mimetype));
},
});
Rate Limiting with express-rate-limit
npm install express-rate-limit
Apply a global rate limit for API protection, with stricter limits on authentication endpoints:
import rateLimit from 'express-rate-limit';
// Global limiter — applies to all routes
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
// Strict limiter for auth routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true, // Only count failed requests
message: { error: 'Too many login attempts. Please try again later.' },
});
app.use(globalLimiter);
app.post('/auth/login', authLimiter, loginHandler);
app.post('/auth/register', authLimiter, registerHandler);
app.post('/auth/forgot-password', authLimiter, forgotPasswordHandler);
For distributed systems where you have multiple Node.js instances, use a Redis store:
npm install rate-limit-redis ioredis
import { RedisStore } from 'rate-limit-redis';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
});
Input Validation with Zod
Never trust user input. Validate the shape, type, and content of every request:
npm install zod
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(12, 'Password must be at least 12 characters'),
name: z.string().min(1).max(100),
role: z.enum(['user', 'editor']).default('user'),
});
// Reusable validation middleware
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.validatedBody = result.data;
next();
};
}
app.post('/users', validate(createUserSchema), async (req, res) => {
const { email, password, name, role } = req.validatedBody;
// All fields are validated and typed
});
Validate query parameters and URL params too:
const listUsersSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().max(100).optional(),
});
app.get('/users', (req, res, next) => {
const result = listUsersSchema.safeParse(req.query);
if (!result.success) return res.status(400).json({ error: 'Invalid query params' });
req.query = result.data;
next();
}, listUsersHandler);
SQL Injection Prevention
If you use a query builder or ORM like Prisma or Knex, parameterized queries are the default. If you write raw SQL, always use parameterized statements:
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL);
// Safe — postgres tag template parameterizes automatically
const users = await sql`SELECT * FROM users WHERE email = ${email}`;
// Safe with knex
const user = await knex('users').where({ email }).first();
// DANGEROUS — string concatenation
const users = await sql.unsafe(`SELECT * FROM users WHERE email = '${email}'`);
Session Management
npm install express-session connect-redis
import session from 'express-session';
import connectRedis from 'connect-redis';
const RedisStore = connectRedis(session);
app.use(
session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
name: '__Host-session', // __Host- prefix prevents subdomain leakage
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
})
);
CORS Configuration
Never use origin: '*' for authenticated APIs:
npm install cors
import cors from 'cors';
const corsOptions = {
origin: (origin, callback) => {
const allowed = [
'https://app.example.com',
'https://www.example.com',
];
// Allow requests with no origin (mobile apps, curl)
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
app.use(cors(corsOptions));
Error Handling: Don't Leak Stack Traces
// Global error handler — must be last middleware
app.use((err, req, res, next) => {
// Log full error internally
logger.error('Unhandled error', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Return generic message to client
const status = err.status || err.statusCode || 500;
res.status(status).json({
error: status < 500 ? err.message : 'An internal error occurred',
});
});
Never let Express's default error handler reach the client in production — it includes a full stack trace.
Remove X-Powered-By
Helmet disables this, but if you're not using Helmet:
app.disable('x-powered-by');
This prevents fingerprinting your Express version.
Security Checklist
-
helmet()applied before all routes - Body parser limits set (100kb or appropriate for your use case)
-
express-rate-limiton all routes, strict limits on auth endpoints - All request bodies validated with Zod or Joi
- No raw SQL with string interpolation
- CORS configured with explicit origin allowlist
- Sessions use HttpOnly + Secure + SameSite cookies
- Global error handler returns generic messages, not stack traces
-
x-powered-byheader disabled -
npm auditin CI pipeline