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
- Security headers with Helmet
- Rate limiting
- CORS configuration
- Input validation with Zod
- Authentication middleware patterns
- SQL / NoSQL injection prevention
- Error handling without leaking internals
- 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 helmetimport 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-limitimport 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 ioredisimport { 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 corsimport 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 zodimport { 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 jsonwebtokenimport 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.
Secure cookie sessions
For server-rendered apps or when you control both client and server:
npm install express-session connect-redisimport 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.params7. 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-errorsIn 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=devAdd to your CI pipeline:
# GitHub Actions example
- name: Security audit
run: npm audit --audit-level=highnpm 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
| Control | Package | Where to apply |
|---|---|---|
| Security headers | helmet | First middleware |
| Rate limiting | express-rate-limit | Global + per-route for auth |
| CORS | cors | Before routes |
| Body parsing | express.json({ limit: '10kb' }) | Before validation |
| Input validation | zod | Per-route middleware |
| Auth | jsonwebtoken | Protected route middleware |
| SQL injection | Parameterized queries | At every DB query |
| NoSQL injection | express-mongo-sanitize | After body parser |
| Error handling | Custom error handler | Last 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.
$ 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.