Express Security — Helmet, CORS, Rate Limiting, and Input Sanitisation

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']
Note: Content Security Policy (CSP) is the most impactful security header but also the most complex to configure. A CSP that is too strict breaks your application (fonts not loading, inline scripts blocked). Start with Helmet’s defaults, which are strict but allow same-origin resources. Then progressively relax by adding exceptions — 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.
Tip: Use a dynamic CORS origin function for multi-environment support. Rather than hard-coding 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.
Warning: Never set 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' })

🧠 Test Yourself

An attacker sends a login request with { "email": { "$gt": "" }, "password": "anything" }. Which middleware prevents this MongoDB injection attack, and how?