Security Headers
CORS, Helmet, and the HTTP headers that protect your API from common attacks.
The Invisible Armor#
HTTP headers are metadata sent with every request and response. Security headers tell browsers how to behave - what's allowed, what's blocked, what's safe.
Without them, your API is vulnerable to:
- Cross-site scripting (XSS)
- Clickjacking
- MIME sniffing attacks
- Cross-site request forgery (CSRF)
- Man-in-the-middle attacks
Most of these attacks are prevented by simple headers. Let's set them up.
Helmet: One Line, Many Headers#
Helmet sets secure HTTP headers automatically:
npm install helmet
import express from 'express';
import helmet from 'helmet';
const app = express();
app.use(helmet()); // That's it - you're protected
// Or configure specific headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
crossOriginEmbedderPolicy: false, // Disable if causing issues
}));
What Helmet Sets#
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 0
Strict-Transport-Security: max-age=15552000; includeSubDomains
Content-Security-Policy: default-src 'self'; ...
CORS: Cross-Origin Resource Sharing#
The biggest source of confusion for new developers. Let's demystify it.
The Problem#
Browsers block requests from one origin to another by default:
- Your frontend:
https://myapp.com - Your API:
https://api.myapp.com
Different origins! Browser blocks the request.
The Solution#
CORS headers tell the browser "it's okay, this origin is allowed":
npm install cors
import cors from 'cors';
// Allow all origins (development only!)
app.use(cors());
// Allow specific origin
app.use(cors({
origin: 'https://myapp.com',
}));
// Multiple origins
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
}));
// Dynamic origin (check against whitelist)
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
'http://localhost:3000', // Development
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
}));
Full CORS Configuration#
app.use(cors({
origin: process.env.CORS_ORIGIN || 'https://myapp.com',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'], // Headers client can read
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
}));
Common CORS Errors#
Access to fetch at 'https://api.example.com' from origin
'https://myapp.com' has been blocked by CORS policy
Fix: Add the frontend origin to your CORS config.
No 'Access-Control-Allow-Origin' header is present
Fix: Make sure cors() middleware runs before your routes.
Request header field X-Custom-Header is not allowed
Fix: Add the header to allowedHeaders.
CORS is Browser Security
CORS only affects browsers. Server-to-server requests (Node.js, curl, Postman) ignore CORS entirely. It's not API security - it's browser security.
Individual Security Headers#
Content-Security-Policy#
Controls what resources the browser can load:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "*.cloudinary.com"],
fontSrc: ["'self'", "fonts.gstatic.com"],
connectSrc: ["'self'", "api.example.com"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
},
}));
X-Frame-Options#
Prevents clickjacking (your site being embedded in an iframe):
app.use(helmet.frameguard({ action: 'deny' }));
// or
app.use(helmet.frameguard({ action: 'sameorigin' }));
Strict-Transport-Security (HSTS)#
Forces HTTPS connections:
app.use(helmet.hsts({
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
}));
X-Content-Type-Options#
Prevents MIME sniffing:
app.use(helmet.noSniff());
// Sets: X-Content-Type-Options: nosniff
Rate Limiting#
Prevent abuse and brute force attacks:
npm install express-rate-limit
import rateLimit from 'express-rate-limit';
// General rate limit
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, slow down' },
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
});
app.use(limiter);
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts
message: { error: 'Too many login attempts' },
});
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
Rate Limit by User#
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per minute
keyGenerator: (req) => {
// Rate limit by user ID if authenticated, IP otherwise
return req.user?.id || req.ip;
},
});
Request Size Limits#
Prevent denial of service via large payloads:
// Limit JSON body size
app.use(express.json({ limit: '10kb' }));
// Limit URL-encoded body size
app.use(express.urlencoded({ limit: '10kb', extended: true }));
// Limit specific endpoints differently
app.use('/api/upload', express.json({ limit: '10mb' }));
Complete Security Setup#
// src/app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { config } from './config/index.js';
const app = express();
// Security headers
app.use(helmet());
// CORS
app.use(cors({
origin: config.corsOrigin,
credentials: true,
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// Auth rate limiting (stricter)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
});
app.use('/api/auth', authLimiter);
// Body size limits
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ limit: '10kb', extended: true }));
// Trust proxy (if behind load balancer)
if (config.env === 'production') {
app.set('trust proxy', 1);
}
// Your routes
app.use('/api', routes);
export { app };
Testing Security Headers#
Check your headers:
# Using curl
curl -I https://your-api.com/api/health
# Should see:
# X-Content-Type-Options: nosniff
# X-Frame-Options: SAMEORIGIN
# Strict-Transport-Security: max-age=...
# Content-Security-Policy: ...
Or use securityheaders.com to scan your site.
Environment-Specific Config#
// src/config/security.js
export const securityConfig = {
cors: {
origin: process.env.NODE_ENV === 'production'
? ['https://myapp.com', 'https://admin.myapp.com']
: ['http://localhost:3000', 'http://localhost:5173'],
credentials: true,
},
rateLimit: {
windowMs: 15 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? 100 : 1000,
},
helmet: {
contentSecurityPolicy: process.env.NODE_ENV === 'production',
},
};
Key Takeaways#
- Use Helmet - One line, many protections
- Configure CORS properly - Whitelist your frontend origins
- Rate limit everything - Especially auth endpoints
- Limit request sizes - Prevent DoS attacks
- Test your headers - Use curl or online scanners
- Different configs for different environments - Stricter in production
Security Checklist
- Helmet middleware added
- CORS configured for your frontend
- Rate limiting on all endpoints
- Stricter rate limits on auth endpoints
- Request body size limits set
- HTTPS in production (HSTS enabled)
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.