Security Best Practices
Express gives you a minimal, unopinionated core — which means security is your responsibility, not the framework’s. A handful of disciplines cover the bulk of the OWASP Top 10 for a typical API: set safe HTTP headers, validate and sanitize every input, throttle abusive clients, never build queries by string concatenation, keep secrets out of code, terminate TLS, and audit your dependency tree. None of these are hard; the trap is forgetting one. This page walks through each with runnable Express 4.x (and 5.x-aware) code.
Set secure headers with Helmet
By default Express sends an X-Powered-By: Express header and omits hardening headers entirely. Helmet fixes this in one line by setting a sensible bundle of security headers — Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, and more. Mount it first, before any route.
npm install helmet
import express from 'express';
import helmet from 'helmet';
const app = express();
app.use(helmet()); // applies the full default header set
// Tighten the CSP for an app that only loads its own assets
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
},
})
);
A response now carries the protective headers automatically:
Output:
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Tip: Helmet does not remove
X-Powered-Byunless mounted — but you can also disable it explicitly withapp.disable('x-powered-by'). Hiding the framework banner gives attackers one less hint.
Validate and sanitize all input
Treat every value from the client — body, query, params, headers — as hostile until proven otherwise. Schema validation rejects malformed input at the edge so it never reaches your business logic or database. Libraries like Zod or express-validator make this declarative.
npm install zod
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13).max(120),
name: z.string().trim().min(1).max(100),
});
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 });
}
req.body = result.data; // use the parsed, coerced, trimmed values
next();
};
}
app.post('/users', validate(createUserSchema), async (req, res) => {
const user = await createUser(req.body);
res.status(201).json(user);
});
Invalid input is rejected before it touches your handler:
Output:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{ "error": "Validation failed", "issues": [ { "path": ["email"], "message": "Invalid email" } ] }
Rate limit to blunt abuse
Without throttling, a single client can brute-force logins or exhaust your resources. express-rate-limit caps requests per IP over a time window. Apply a strict limit to auth endpoints and a looser one globally.
npm install express-rate-limit
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
});
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.use('/api', apiLimiter);
app.use('/api/login', loginLimiter);
Warning: Behind a proxy or load balancer, set
app.set('trust proxy', 1)so the limiter sees the real client IP fromX-Forwarded-Forrather than the proxy’s address — otherwise every request looks like one IP.
Use parameterized queries
String-concatenating user input into SQL is the classic injection hole. Always use parameter placeholders so the driver — not your string-building — separates code from data. The same rule applies to NoSQL: never pass raw user objects into query filters.
// BAD — vulnerable to SQL injection
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// GOOD — parameterized; the driver escapes the value
const result = await pool.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email]
);
With an ORM like Prisma or a query builder like Knex, parameterization happens automatically — prefer those over hand-written SQL when you can.
Manage secrets safely
Never commit API keys, database passwords, or signing secrets to source control. Load them from environment variables and keep .env files out of git. Use a real secret manager (AWS Secrets Manager, Vault, Doppler) in production.
import 'dotenv/config';
const config = {
jwtSecret: process.env.JWT_SECRET,
dbUrl: process.env.DATABASE_URL,
};
// Fail fast at startup if a required secret is missing
for (const [key, value] of Object.entries(config)) {
if (!value) throw new Error(`Missing required env var for ${key}`);
}
Add .env and .env.* to .gitignore, and validate that all required variables are present at boot so a missing secret crashes startup instead of leaking 500s at runtime.
Enforce HTTPS
All traffic should be encrypted in transit. Terminate TLS at a reverse proxy (Nginx, Caddy) or load balancer, then redirect any plaintext requests and tell browsers to stay on HTTPS via HSTS (Helmet sets this for you).
app.set('trust proxy', 1);
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
}
next();
});
Audit dependencies
Most production vulnerabilities arrive through transitive dependencies, not your own code. Run npm audit in CI and patch high-severity advisories promptly. Automate updates with Dependabot or Renovate.
npm audit # list known advisories
npm audit fix # apply non-breaking patches
npm audit --audit-level=high --omit=dev # fail CI on high-severity prod issues
Output:
found 0 vulnerabilities
Header and middleware reference
| Concern | Tool | What it does |
|---|---|---|
| HTTP headers | helmet | CSP, HSTS, nosniff, frame options |
| Input validation | zod / express-validator | Reject malformed requests at the edge |
| Rate limiting | express-rate-limit | Cap requests per IP per window |
| CORS | cors | Restrict which origins may call your API |
| Secrets | dotenv + secret manager | Keep credentials out of code |
Best Practices
- Mount
helmet()first andapp.disable('x-powered-by')to ship safe headers and hide the framework banner. - Validate every request body, query, and param with a schema; replace the raw input with parsed, sanitized values.
- Rate-limit globally and apply a stricter limit to auth endpoints; set
trust proxyso limits key on the real client IP. - Use parameterized queries or an ORM exclusively — never concatenate user input into SQL or NoSQL filters.
- Load secrets from environment variables or a secret manager, keep
.envout of git, and fail fast at startup if any are missing. - Enforce HTTPS everywhere with TLS termination, an HTTPS redirect, and HSTS.
- Run
npm auditin CI and automate dependency updates with Dependabot or Renovate.