Express is minimal by design. It does not include security features out of the box, which means every protection is something you must add explicitly. That minimalism is a strength for flexibility, but a weakness for security. This guide covers the essential hardening steps for production Express applications.
Helmet: Security Headers in One Line
Helmet sets essential HTTP security headers:
const helmet = require('helmet');
const app = express();
app.use(helmet());
Helmet enables these headers by default:
Content-Security-PolicyCross-Origin-Opener-PolicyCross-Origin-Resource-PolicyOrigin-Agent-ClusterReferrer-PolicyStrict-Transport-SecurityX-Content-Type-OptionsX-DNS-Prefetch-ControlX-Download-OptionsX-Frame-OptionsX-Permitted-Cross-Domain-PoliciesX-Powered-By(removed)
Customize the CSP for your application:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
},
},
}));
Input Validation
Express does not validate input. Every route needs explicit validation. Use a validation library like Joi or express-validator:
const { body, validationResult } = require('express-validator');
app.post('/api/users',
body('email').isEmail().normalizeEmail(),
body('name').trim().isLength({ min: 1, max: 100 }).escape(),
body('role').isIn(['user', 'editor']),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process validated input
}
);
Validate everything: body, query parameters, URL parameters, and headers. Attackers target all of these.
Prototype Pollution Prevention
Node.js objects are vulnerable to prototype pollution through __proto__, constructor, and prototype properties in user input:
// Vulnerable to prototype pollution
const config = Object.assign({}, defaultConfig, req.body);
// Safe: use a validated schema
const config = {
theme: req.body.theme || 'default',
language: req.body.language || 'en',
};
Use Object.create(null) for dictionary objects, and validate input shapes before merging.
Rate Limiting
Without rate limiting, your API is open to brute force attacks, credential stuffing, and resource exhaustion:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', limiter);
// Stricter limit for authentication endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
});
app.use('/api/auth/', authLimiter);
For distributed applications, use a Redis-backed rate limiter instead of the default in-memory store.
Session Security
If you use sessions (not token-based auth), configure them securely:
const session = require('express-session');
const RedisStore = require('connect-redis').default;
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sessionId', // Don't use the default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
maxAge: 3600000, // 1 hour
sameSite: 'strict', // CSRF protection
},
}));
Never use the in-memory session store in production. It leaks memory and does not work with multiple server instances.
CORS Configuration
Configure CORS explicitly. Never use origin: '*' with credentials:
const cors = require('cors');
app.use(cors({
origin: ['https://yourapp.com', 'https://www.yourapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // Cache preflight for 24 hours
}));
Error Handling
Never expose stack traces or internal error details to clients:
// Production error handler
app.use((err, req, res, next) => {
console.error(err.stack); // Log full error server-side
// Send generic error to client
res.status(err.status || 500).json({
error: {
message: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
},
});
});
Body Parsing Limits
Set limits on request body size to prevent denial-of-service attacks:
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
10KB is generous for most JSON APIs. Adjust based on your needs, but always set a limit.
Production Deployment
Environment Variables
Never hardcode secrets:
// Bad
const dbPassword = 'supersecret123';
// Good
const dbPassword = process.env.DB_PASSWORD;
if (!dbPassword) {
throw new Error('DB_PASSWORD environment variable is required');
}
Process Management
Run Express behind a reverse proxy (nginx, Caddy) that handles:
- TLS termination
- Request size limits
- Connection timeouts
- Static file serving
Run Node.js as a non-root user:
FROM node:20-slim
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "server.js"]
Dependency Security
Express applications live in the npm ecosystem. Apply standard npm security practices:
npm ci
npm audit --audit-level=high
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
How Safeguard.sh Helps
Safeguard.sh monitors the full dependency tree of your Express applications. It tracks every npm package version across your services, correlates against vulnerability databases in real time, and provides a consolidated dashboard for your Node.js supply chain risk. When a critical CVE hits Express or any middleware package, Safeguard.sh identifies every affected service in your organization and delivers prioritized remediation steps to your team.