Security defaults that belong in every Express application from day one.
93
90%
Does it follow best practices?
Impact
99%
6.18xAverage score across 5 eval scenarios
Passed
No known issues
Every Express app needs these security defaults from day one -- do not wait for a security review to add them. These are not optional hardening steps; they are baseline requirements for any Express application, the same way you would not ship a car without seatbelts.
When to apply this skill: Every time you create a new Express application, add routes to an existing one, or refactor an Express project. Even if the task only asks you to "build a REST API" or "create CRUD endpoints," you MUST include all six of these middleware. An Express app without them is incomplete.
These six items are as fundamental as express.json(). If your app parses JSON but lacks these, it is not ready for any environment -- not development, not staging, not production.
npm install helmetWRONG -- no security headers at all:
const app = express();
app.use(express.json());
// Routes defined here without any security headersRIGHT -- Helmet before all routes:
import helmet from 'helmet';
const app = express();
app.use(helmet()); // MUST be before all route handlers
app.use(express.json());Helmet adds these security headers by default:
Content-Security-Policy -- prevents XSS by restricting resource originsCross-Origin-Opener-Policy -- isolates browsing contextCross-Origin-Resource-Policy -- blocks cross-origin readsX-Content-Type-Options: nosniff -- prevents MIME-type sniffingX-Frame-Options: SAMEORIGIN -- prevents clickjackingX-DNS-Prefetch-Control: off -- disables DNS prefetchingStrict-Transport-Security -- enforces HTTPS for 180 daysX-Download-Options: noopen -- prevents IE file downloads from executingX-Permitted-Cross-Domain-Policies: none -- blocks Flash/PDF cross-domainX-Powered-By is REMOVED (hides Express fingerprint)X-XSS-Protection: 0 -- disables broken legacy XSS filterWRONG -- disabling CSP entirely:
app.use(helmet({
contentSecurityPolicy: false, // Never disable CSP
}));RIGHT -- customize directives for your app's needs:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://cdn.example.com"],
}
}
}));For API-only servers that never serve HTML, you can relax CSP since there is no browser document to protect:
app.use(helmet({
contentSecurityPolicy: false, // OK for pure JSON APIs behind a gateway
}));npm install corsWRONG -- allows any origin (this is the default):
app.use(cors()); // Equivalent to Access-Control-Allow-Origin: *WRONG -- reflecting the request origin (even worse than wildcard):
app.use(cors({
origin: true, // Reflects any origin, allows credentials -- very dangerous
credentials: true,
}));RIGHT -- explicit allowed origins:
import cors from 'cors';
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'],
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Only if you use cookies/sessions
}));RIGHT -- dynamic origin validation for multiple environments:
const allowedOrigins = new Set(
(process.env.ALLOWED_ORIGINS || 'http://localhost:5173').split(',')
);
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl, etc.)
if (!origin || allowedOrigins.has(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}));If Express serves both the API and static files (common in prototypes), you may not need CORS at all -- same-origin requests do not trigger CORS. Only add it if the frontend is on a different port or domain.
// Only add CORS if frontend is on a separate origin
if (process.env.FRONTEND_URL) {
app.use(cors({ origin: process.env.FRONTEND_URL }));
}npm install express-rate-limitWRONG -- no rate limiting at all:
app.post('/api/auth/login', loginHandler); // Unlimited login attemptsRIGHT -- general API rate limit plus stricter limits for sensitive and mutation endpoints:
import rateLimit from 'express-rate-limit';
// General API rate limit (applies to all /api routes)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // Return RateLimit-* headers
legacyHeaders: false, // Disable X-RateLimit-* headers
message: {
error: { code: 'RATE_LIMITED', message: 'Too many requests, try again later' }
}
});
app.use('/api', apiLimiter);
// Stricter limit for auth endpoints (prevent brute force)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window
standardHeaders: true,
legacyHeaders: false,
message: {
error: { code: 'RATE_LIMITED', message: 'Too many login attempts, try again later' }
}
});
app.use('/api/auth', authLimiter);
// Stricter limit for mutation/write endpoints (POST, PATCH, DELETE)
const mutationLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 writes per minute
standardHeaders: true,
legacyHeaders: false,
message: {
error: { code: 'RATE_LIMITED', message: 'Write rate limit exceeded' }
}
});
// Apply to specific write endpoints
app.post('/api/orders', mutationLimiter);
app.patch('/api/orders/:id', mutationLimiter);
app.delete('/api/orders/:id', mutationLimiter);Always apply a stricter rate limit on mutation endpoints (POST, PATCH, DELETE) than the general read limit. Write operations are more expensive and more dangerous when abused.
WRONG -- using the default memory store in production with multiple processes:
// Memory store does NOT share state across cluster workers or multiple instances
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
// Each process tracks limits independently -- ineffective at scaleRIGHT -- use Redis or another external store in production:
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
});When Express runs behind a reverse proxy (nginx, AWS ALB, Cloudflare, etc.), req.ip returns the proxy's IP, not the client's. This breaks rate limiting and IP logging.
WRONG -- rate limiting sees the proxy IP, so all users share one limit:
const app = express();
// trust proxy not set -- req.ip is always the load balancer IP
app.use(rateLimit({ windowMs: 60000, max: 10 })); // Blocks ALL users after 10 total requestsRIGHT -- tell Express how many proxies to trust:
const app = express();
// Behind one proxy (nginx, ALB, etc.)
app.set('trust proxy', 1);
// Now req.ip reads from X-Forwarded-For correctly
app.use(rateLimit({ windowMs: 60000, max: 10 })); // Per-client limiting worksWRONG -- trusting all proxies blindly:
app.set('trust proxy', true); // Client can spoof X-Forwarded-For -- never do this in productionSet trust proxy to the exact number of proxies between the client and Express (1 for a single reverse proxy, 2 for CDN + load balancer, etc.), or use 'loopback' for localhost proxies.
WRONG -- accepting unlimited body sizes and parsing unnecessary formats:
app.use(express.json()); // Default limit is 100kb -- often too generous
app.use(express.urlencoded({ extended: true })); // Don't enable unless you actually need form submissionsRIGHT -- restrict body size and only parse what you need:
app.use(express.json({ limit: '10kb' }));
// Only add urlencoded parsing if your API actually accepts form-encoded data.
// Most JSON APIs do not need it. Remove it if unused -- it's extra attack surface.
// app.use(express.urlencoded({ extended: false, limit: '10kb' }));When reviewing existing code, if you see express.urlencoded() but the API only accepts JSON, remove it or comment it out. Every parser you enable is additional attack surface.
WRONG -- trusting user input directly:
router.get('/api/users/:id', async (req, res) => {
const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`); // SQL injection
res.json(user);
});RIGHT -- validate and sanitize all input:
router.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id) || id <= 0) {
return res.status(400).json({
error: { code: 'INVALID_PARAM', message: 'Invalid user ID' }
});
}
// Use parameterized queries
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
res.json(user);
});WRONG -- accepting duplicate query parameters without handling:
// GET /api/search?sort=name&sort=; DROP TABLE users--
// req.query.sort could be an array, causing unexpected behavior
router.get('/api/search', (req, res) => {
const results = db.query(`SELECT * FROM items ORDER BY ${req.query.sort}`);
});RIGHT -- use hpp middleware and validate query params:
npm install hppimport hpp from 'hpp';
app.use(hpp()); // Picks last value for duplicate params, puts array in req.query.polluted
// Always validate and whitelist query parameter values
router.get('/api/search', (req, res) => {
const allowedSortFields = ['name', 'date', 'price'];
const sort = allowedSortFields.includes(req.query.sort as string) ? req.query.sort : 'date';
// Use parameterized queries with validated values
});WRONG -- leaking stack traces and internal details:
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack, // Exposes file paths, line numbers, dependencies
query: req.query, // Echoes back user input
});
});WRONG -- no custom error handler (Express default shows HTML stack in dev):
// No error handler defined -- Express shows full stack trace in development modeRIGHT -- safe error handler that logs internally but returns minimal info:
// Error handler MUST have 4 parameters (err, req, res, next) to be recognized by Express
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
// Log full error internally
console.error(`[${new Date().toISOString()}] ${req.method} ${req.path}`, err);
// Never send stack traces or internal details to the client
const statusCode = (err as any).statusCode || 500;
res.status(statusCode).json({
error: {
code: statusCode === 500 ? 'INTERNAL_ERROR' : (err as any).code || 'ERROR',
message: statusCode === 500 ? 'An unexpected error occurred' : err.message,
}
});
});If your app uses sessions (express-session, cookie-session), configure cookies securely.
WRONG -- insecure session cookies:
app.use(session({
secret: 'keyboard cat', // Weak, hardcoded secret
cookie: {}, // No security flags set
}));RIGHT -- secure session configuration:
app.use(session({
secret: process.env.SESSION_SECRET, // Strong secret from environment
name: '__Host-sid', // Custom name, __Host- prefix enforces HTTPS + no subdomain
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // Prevents CSRF on cross-site GET
maxAge: 24 * 60 * 60 * 1000, // 24 hours
}
}));Key cookie flags:
httpOnly: true -- prevents document.cookie access (blocks XSS cookie theft)secure: true -- cookie only sent over HTTPSsameSite: 'lax' -- prevents CSRF by blocking cross-site POST requestssameSite: 'strict' -- blocks all cross-site requests (breaks OAuth redirects)The order middleware is registered matters. Security middleware MUST come first.
const app = express();
// 1. Trust proxy (before anything that reads req.ip)
app.set('trust proxy', 1);
// 2. Security headers
app.use(helmet());
// 3. CORS
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
// 4. Rate limiting
app.use('/api', apiLimiter);
app.use('/api/auth', authLimiter);
// 5. Body parsing with limits
app.use(express.json({ limit: '10kb' }));
// 6. Parameter pollution protection
app.use(hpp());
// 7. Routes
app.use('/api', apiRouter);
// 8. Error handler (MUST be last)
app.use(errorHandler);This is not a "production checklist." These belong in every Express app from the start:
helmet() middleware registered before all routescors() with no arguments)trust proxy set to the correct number of proxy hopsexpress.json({ limit: '10kb' }) -- body size restrictedWhen using sessions:
httpOnly: true, secure: true, sameSite: 'lax'Recommended extras:
hpp() middleware to prevent parameter pollutionIf the task says "build a REST API" or "create CRUD endpoints" and does not mention security, you still add all of the above. Security middleware is not a feature request -- it is part of building an Express app correctly.