CORS, Helmet, Rate Limiting and CSRF
A production Express API faces a constant stream of security threats: cross-origin requests from unauthorised domains (CORS), missing security headers that leak server information (Helmet), brute-force and denial-of-service attacks (rate limiting), and forged state-changing requests from malicious sites (CSRF). Each threat requires a distinct defence. This lesson covers the four most essential security middleware layers for any production Express application.
CORS — Cross-Origin Resource Sharing
Browsers enforce the Same-Origin Policy: JavaScript on domain-a.com cannot make fetch requests to domain-b.com unless the server explicitly permits it. CORS headers on the response tell the browser which origins, methods, and headers are allowed. Express's cors package makes this configurable. Misconfigured CORS (e.g., origin: '*' with credentials: true) is a critical security vulnerability.
npm install corsconst cors = require('cors');
// Allow specific origins
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (curl, Postman, mobile apps)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS: origin not allowed'));
}
},
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies (required for refresh token cookies)
maxAge: 86400 // Cache preflight for 24 hours
}));Helmet — Security Headers
Helmet is a collection of small middleware functions that set HTTP response headers to protect against well-known vulnerabilities: Content-Security-Policy prevents XSS, X-Frame-Options prevents clickjacking, Strict-Transport-Security enforces HTTPS, X-Content-Type-Options prevents MIME sniffing. In Express 5+, Helmet is no longer bundled and must be installed separately.
npm install helmetconst helmet = require('helmet');
// Use Helmet with a sensible default configuration
app.use(helmet());
// Customise Content Security Policy for your app
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://res.cloudinary.com"],
connectSrc: ["'self'", "https://api.myapp.com"],
frameAncestors: ["'none'"] // Prevents clickjacking
}
}));
// Helmet sets these headers automatically:
// X-DNS-Prefetch-Control: off
// X-Frame-Options: SAMEORIGIN
// X-Content-Type-Options: nosniff
// Referrer-Policy: no-referrer
// Strict-Transport-Security: max-age=15552000
// X-Permitted-Cross-Domain-Policies: noneRate Limiting
Rate limiting restricts how many requests a client can make within a time window. Without it, your API is vulnerable to brute-force attacks on login endpoints, credential stuffing, and application-layer denial-of-service. The express-rate-limit package provides a flexible in-process store. For production with multiple server instances, use a Redis-backed store (rate-limit-redis).
npm install express-rate-limitconst rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Only 10 login attempts per 15 min
message: { error: 'Too many login attempts' }
});
app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
app.use('/auth/register', authLimiter);In production with multiple Node.js instances (PM2 cluster, Kubernetes), store rate limit counters in Redis so limits are shared across all processes. The rate-limit-redis package provides a Redis-compatible store for express-rate-limit.
How CSRF Protection Works
CSRF (Cross-Site Request Forgery) attacks trick an authenticated user's browser into making a state-changing request to your API. The browser automatically sends cookies on cross-origin requests, so if your API uses cookie-based sessions, a malicious form on evil.com can POST to your /transfer endpoint with the victim's session cookie. The Double Submit Cookie pattern and the csurf library (deprecated) or csrf-csrf defend against this.
npm install csrf-csrfconst { doubleCsrf } = require('csrf-csrf');
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__Host-psifi.x-csrf-token',
cookieOptions: { sameSite: 'strict', secure: true, httpOnly: true },
size: 64,
getTokenFromRequest: (req) => req.headers['x-csrf-token']
});
// Provide CSRF token to the frontend
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: generateToken(req, res) });
});
// Protect all state-changing routes
app.use('/api', doubleCsrfProtection);
// Frontend: fetch the token on app load, attach it to every mutation request
// fetch('/api/csrf-token').then(r => r.json()).then(d => {
// axios.defaults.headers.common['x-csrf-token'] = d.csrfToken;
// });JWT-based APIs that use Authorization: Bearer <token> headers (not cookies) are inherently CSRF-safe because browsers do not automatically include the Authorization header in cross-origin requests. CSRF protection is only required when you use cookies for authentication.