helmet vs cors vs express-rate-limit: Express Security Middleware (2026)
TL;DR
These three packages are not alternatives — they solve different security problems and you should use all three together. helmet sets HTTP security headers (CSP, HSTS, X-Frame-Options, etc.) that protect against XSS, clickjacking, and MIME sniffing attacks. cors configures Cross-Origin Resource Sharing — controls which domains can call your API from a browser. express-rate-limit limits how many requests a client can make — protects against brute force attacks, DoS, and API abuse. In 2026: install all three as standard security baseline for any Express API.
Key Takeaways
- helmet: ~3M weekly downloads — sets 15+ HTTP security headers, one-liner protection
- cors: ~15M weekly downloads — CORS preflight + actual request header management
- express-rate-limit: ~3M weekly downloads — IP-based rate limiting with flexible stores
- These solve different attack vectors: headers vs cross-origin vs request volume
- helmet is a near-zero-config must-have:
app.use(helmet()) - CORS misconfigurations are a top API security issue — be explicit about allowed origins
The Minimal Secure Express App
import express from "express"
import helmet from "helmet"
import cors from "cors"
import rateLimit from "express-rate-limit"
const app = express()
// 1. Security headers — block XSS, clickjacking, MIME sniffing:
app.use(helmet())
// 2. CORS — only allow your frontend domain:
app.use(cors({
origin: process.env.FRONTEND_URL ?? "https://www.pkgpulse.com",
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
}))
// 3. Rate limiting — 100 requests per 15 minutes per IP:
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
}))
app.get("/api/packages", (req, res) => {
res.json({ packages: [] })
})
helmet
helmet — HTTP security headers:
What headers helmet sets
import helmet from "helmet"
// Default helmet() enables all headers below:
app.use(helmet())
// Individual headers (for fine-grained control):
// Content-Security-Policy — limits sources for scripts, styles, images:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts (common for CRAs)
styleSrc: ["'self'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https://cdn.pkgpulse.com"],
connectSrc: ["'self'", "https://api.pkgpulse.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [], // Auto-upgrade HTTP to HTTPS
},
}))
// HTTP Strict Transport Security — force HTTPS:
app.use(helmet.hsts({
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true,
}))
// X-Frame-Options — prevent clickjacking:
app.use(helmet.frameguard({ action: "deny" }))
// X-Content-Type-Options — prevent MIME sniffing:
app.use(helmet.noSniff())
// Referrer-Policy:
app.use(helmet.referrerPolicy({ policy: "strict-origin-when-cross-origin" }))
// Permissions-Policy (formerly Feature-Policy):
app.use(helmet.permittedCrossDomainPolicies())
// X-Powered-By is removed by helmet (hides "Express"):
// No more: X-Powered-By: Express
Customize for Next.js or SPAs
// If serving a React SPA with inline scripts/styles, CSP needs adjustment:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // CRA requires unsafe-eval
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "blob:", "https:"],
},
},
// Disable cross-origin embedder policy if loading cross-origin resources:
crossOriginEmbedderPolicy: false,
})
)
What does helmet protect against?
XSS (Cross-Site Scripting):
→ Content-Security-Policy restricts which scripts can execute
→ If attacker injects <script>..., CSP blocks it from running
Clickjacking:
→ X-Frame-Options: DENY prevents your page from being embedded in an iframe
→ Attacker can't overlay an invisible iframe over legitimate UI
MIME sniffing:
→ X-Content-Type-Options: nosniff prevents browsers from guessing file types
→ Attacker can't trick browser into executing a text file as JavaScript
Man-in-the-middle:
→ HSTS forces all connections over HTTPS, even if user types http://
→ Cookies can't be intercepted over plain HTTP
cors
cors — Cross-Origin Resource Sharing:
Basic CORS setup
import cors from "cors"
// Allow all origins (dangerous — only for public APIs):
app.use(cors())
// Allow specific origin:
app.use(cors({
origin: "https://www.pkgpulse.com",
}))
// Allow multiple origins:
const allowedOrigins = [
"https://www.pkgpulse.com",
"https://app.pkgpulse.com",
...(process.env.NODE_ENV === "development" ? ["http://localhost:3000"] : []),
]
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps, Postman, curl):
if (!origin) return callback(null, true)
if (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", "X-Request-ID"],
credentials: true, // Allow cookies + Authorization headers
maxAge: 86400, // Cache preflight for 24 hours
}))
Per-route CORS
import cors from "cors"
const publicCors = cors({
origin: "*", // Any origin
methods: ["GET"],
})
const privateCors = cors({
origin: "https://app.pkgpulse.com",
credentials: true,
})
// Public API — any origin can read:
app.get("/api/packages/public", publicCors, (req, res) => {
res.json({ packages: [] })
})
// Private API — only app.pkgpulse.com with credentials:
app.post("/api/packages", privateCors, (req, res) => {
// ...
})
CORS preflight explained
CORS preflight for non-simple requests (POST with JSON, PUT, DELETE, custom headers):
Browser sends OPTIONS first:
OPTIONS /api/packages HTTP/1.1
Origin: https://www.pkgpulse.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Server responds with CORS headers:
Access-Control-Allow-Origin: https://www.pkgpulse.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 ← Cache this for 24h, don't ask again
Then browser sends the actual request.
The cors package handles both the OPTIONS response AND adding headers to actual requests.
Common CORS mistakes
// ❌ MISTAKE 1: Reflecting the Origin header without validation:
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin) // Allows ANY origin!
next()
})
// ❌ MISTAKE 2: Wildcard + credentials (browsers reject this):
app.use(cors({
origin: "*",
credentials: true, // Browsers reject: can't combine * with credentials
}))
// Error: The value of the 'Access-Control-Allow-Credentials' header in the response
// is '' which must be 'true' when the request's credentials mode is 'include'.
// ✅ CORRECT: Explicit origin + credentials:
app.use(cors({
origin: "https://www.pkgpulse.com",
credentials: true,
}))
express-rate-limit
express-rate-limit — IP-based request rate limiting:
Basic rate limiting
import rateLimit from "express-rate-limit"
// Global rate limit:
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minute window
max: 100, // Max 100 requests per window per IP
message: { error: "Too many requests, please try again later." },
standardHeaders: true, // RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset headers
legacyHeaders: false, // Disable X-RateLimit-* headers
})
app.use(globalLimiter)
Stricter limits for sensitive routes
import rateLimit from "express-rate-limit"
// Login endpoint — prevent brute force:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window
message: { error: "Too many login attempts. Wait 15 minutes." },
skipSuccessfulRequests: true, // Don't count successful logins
})
app.post("/auth/login", authLimiter, loginHandler)
app.post("/auth/register", authLimiter, registerHandler)
app.post("/auth/forgot-password", rateLimit({ windowMs: 60 * 60 * 1000, max: 3 }), forgotPasswordHandler)
// API endpoints — higher limits:
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests/minute
})
app.use("/api/", apiLimiter)
Redis store for distributed systems
import rateLimit from "express-rate-limit"
import RedisStore from "rate-limit-redis"
import { createClient } from "redis"
// In-memory rate limiting doesn't work across multiple server instances!
// Use Redis to share rate limit state across all instances:
const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
// Key generator — rate limit by IP:
keyGenerator: (req) => req.ip ?? "unknown",
})
app.use(limiter)
Custom response and skip logic
import rateLimit from "express-rate-limit"
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
// Skip rate limiting for authenticated users with API keys:
skip: (req) => {
const apiKey = req.headers["x-api-key"]
return apiKey === process.env.INTERNAL_API_KEY
},
// Custom response when limit exceeded:
handler: (req, res) => {
res.status(429).json({
error: "Rate limit exceeded",
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
limit: req.rateLimit.limit,
current: req.rateLimit.current,
})
},
})
Full Production Middleware Stack
import express from "express"
import helmet from "helmet"
import cors from "cors"
import rateLimit from "express-rate-limit"
import RedisStore from "rate-limit-redis"
import { createClient } from "redis"
import compression from "compression"
const app = express()
const redisClient = createClient({ url: process.env.REDIS_URL })
// Trust proxy (Nginx, Cloudflare):
app.set("trust proxy", 1)
// 1. Compression (before security headers):
app.use(compression())
// 2. Security headers:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
upgradeInsecureRequests: [],
},
},
})
)
// 3. CORS:
app.use(
cors({
origin: (origin, cb) => {
const allowed = new Set([
"https://www.pkgpulse.com",
"https://app.pkgpulse.com",
...(process.env.NODE_ENV !== "production" ? ["http://localhost:3000"] : []),
])
cb(null, !origin || allowed.has(origin))
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
maxAge: 86400,
})
)
// 4. Global rate limit:
app.use(
rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...a: string[]) => redisClient.sendCommand(a) }),
})
)
// 5. Body parsing:
app.use(express.json({ limit: "10mb" }))
app.use(express.urlencoded({ extended: true, limit: "10mb" }))
// 6. Routes:
app.use("/api", apiRoutes)
Feature Comparison
| Feature | helmet | cors | express-rate-limit |
|---|---|---|---|
| Attack type | XSS, clickjacking, MIME | CSRF, cross-origin | Brute force, DoS |
| Config complexity | Low | Medium | Low-Medium |
| Required for all APIs? | ✅ Yes | ✅ Usually | ✅ Yes |
| Distributed support | N/A | N/A | ✅ (Redis store) |
| Per-route config | ✅ | ✅ | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
| Weekly downloads | ~3M | ~15M | ~3M |
When to Use Each
Use all three — they protect against different attack types. This is not an either-or choice.
helmet is non-negotiable: Add it to every Express app. The default helmet() takes one line and prevents a wide range of attacks with zero downside.
cors requires thought: Be explicit about your allowed origins. Never use origin: "*" for APIs that handle authentication. Always list your exact frontend domain(s).
express-rate-limit prevents abuse: At minimum, apply stricter limits to auth routes (login, register, password reset). Apply a global limit to all API routes. Use Redis store when running multiple instances.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on helmet v8.x, cors v2.x, and express-rate-limit v7.x.