helmet vs cors vs express-rate-limit: Node.js Security Middleware (2026)
TL;DR
These three middleware packages solve different security problems — you need all three. helmet sets HTTP security headers (HSTS, CSP, X-Frame-Options, etc.) that harden your server against common web attacks. cors controls which origins can make cross-origin requests to your API. express-rate-limit prevents abuse by limiting how many requests a client can make. Use them together — they're the baseline security stack for any production Node.js API.
Key Takeaways
- helmet: ~1.8M weekly downloads — sets 14+ security headers in one call
- cors: ~18M weekly downloads — enables secure cross-origin resource sharing
- express-rate-limit: ~1.3M weekly downloads — per-IP rate limiting with Redis store support
- All three are needed — they protect against different attack vectors
- Works with Express, Fastify (via adapters), and vanilla Node.js
- Install all three and configure for production before deploying any API
The Essential Security Middleware Stack
import express from "express"
import helmet from "helmet"
import cors from "cors"
import rateLimit from "express-rate-limit"
const app = express()
// Order matters — security middleware first:
app.use(helmet()) // 1. Security headers
app.use(cors(corsOptions)) // 2. CORS
app.use(limiter) // 3. Rate limiting
app.use(express.json()) // 4. Body parsing (after security)
helmet
helmet sets HTTP security headers that protect against common web vulnerabilities — clickjacking, MIME sniffing, XSS, and more.
Default Headers
import helmet from "helmet"
import express from "express"
const app = express()
app.use(helmet()) // Sets all security headers with sane defaults
// Headers set by helmet() with defaults:
// Content-Security-Policy: default-src 'self';...
// Cross-Origin-Embedder-Policy: require-corp
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Resource-Policy: same-origin
// Origin-Agent-Cluster: ?1
// Referrer-Policy: no-referrer
// Strict-Transport-Security: max-age=15552000; includeSubDomains
// X-Content-Type-Options: nosniff
// X-DNS-Prefetch-Control: off
// X-Download-Options: noopen
// X-Frame-Options: SAMEORIGIN
// X-Permitted-Cross-Domain-Policies: none
// X-XSS-Protection: 0
Content Security Policy (CSP)
CSP is the most impactful header — it controls what resources the browser can load:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"], // Only load from same origin
scriptSrc: [ // Scripts allowed from:
"'self'",
"https://cdn.jsdelivr.net",
"'nonce-{NONCE}'", // Per-request nonce for inline scripts
],
styleSrc: ["'self'", "https://fonts.googleapis.com", "'unsafe-inline'"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"], // Images from HTTPS
connectSrc: ["'self'", "https://api.pkgpulse.com"],
frameSrc: ["'none'"], // No iframes
objectSrc: ["'none'"], // No plugins
upgradeInsecureRequests: [], // Upgrade HTTP → HTTPS
},
},
})
)
Per-Route Header Overrides
// Disable CSP for specific routes (e.g., webhook endpoint):
app.post(
"/webhooks/stripe",
helmet({ contentSecurityPolicy: false }),
express.raw({ type: "application/json" }),
stripeWebhookHandler
)
// Allow iframes for an embed route:
app.get(
"/embed/:chartId",
helmet({ frameguard: false }), // Remove X-Frame-Options
embedChartHandler
)
// API routes — stricter CSP (APIs don't need browser protections):
app.use(
"/api",
helmet({
contentSecurityPolicy: false, // APIs don't serve HTML
crossOriginEmbedderPolicy: false,
})
)
HSTS (HTTPS Only)
// Strict-Transport-Security — force HTTPS for 1 year:
app.use(
helmet({
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true, // Submit to browser preload list
},
})
)
cors
cors controls which origins can access your API — critical for preventing unauthorized cross-origin requests.
Simple Configuration
import cors from "cors"
// Allow all origins (development only — never in production!):
app.use(cors())
// Single allowed origin:
app.use(cors({ origin: "https://pkgpulse.com" }))
// Multiple allowed origins:
app.use(cors({
origin: ["https://pkgpulse.com", "https://www.pkgpulse.com"],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true, // Allow cookies/auth headers
maxAge: 86400, // Cache preflight for 24 hours
}))
Dynamic Origin Validation
// Dynamically approve/reject origins (best for multi-tenant apps):
const corsOptions: cors.CorsOptions = {
origin: (requestOrigin, callback) => {
// Allow requests with no origin (curl, server-to-server):
if (!requestOrigin) {
callback(null, true)
return
}
const allowedOrigins = [
"https://pkgpulse.com",
"https://www.pkgpulse.com",
"https://app.pkgpulse.com",
]
// Also allow development:
if (process.env.NODE_ENV === "development") {
allowedOrigins.push("http://localhost:3000", "http://localhost:5173")
}
if (allowedOrigins.includes(requestOrigin)) {
callback(null, true)
} else {
callback(new Error(`CORS: Origin ${requestOrigin} not allowed`))
}
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Total-Count", "X-Request-ID"],
}
app.use(cors(corsOptions))
Route-Level CORS
// Public API — allow all origins:
app.get("/api/public/:packageName", cors(), publicPackageHandler)
// Private API — only your frontends:
app.use("/api/private", cors(strictCorsOptions), privateRouter)
// Webhook endpoints — no CORS needed (server-to-server):
app.post("/webhooks/github", webhookHandler)
Preflight Handling
// Handle OPTIONS preflight for all routes:
app.options("*", cors(corsOptions))
// Or enable for specific paths:
app.options("/api/*", cors(corsOptions))
express-rate-limit
express-rate-limit prevents API abuse by limiting requests per IP within a time window.
Basic Rate Limiting
import rateLimit from "express-rate-limit"
// Global rate limit — applies to all routes:
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
message: {
error: "Too many requests",
retryAfter: "15 minutes",
},
standardHeaders: true, // Send RateLimit-* headers
legacyHeaders: false, // Disable X-RateLimit-* headers
})
app.use(globalLimiter)
Route-Specific Limits
// Stricter limit for authentication endpoints:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Only 5 login attempts
skipSuccessfulRequests: true, // Don't count successful logins
message: { error: "Too many login attempts. Try again in 15 minutes." },
})
app.post("/api/auth/login", authLimiter, loginHandler)
app.post("/api/auth/forgot-password", authLimiter, forgotPasswordHandler)
// More generous limit for read endpoints:
const readLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 60, // 60 reads/minute
})
app.use("/api/packages", readLimiter)
// Very strict for expensive operations:
const searchLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: "Search is limited to 10 requests per minute.",
})
app.get("/api/search", searchLimiter, searchHandler)
Redis Store for Distributed Rate Limiting
The default in-memory store doesn't work with multiple server instances. Use Redis for a distributed counter:
import rateLimit from "express-rate-limit"
import { RedisStore } from "rate-limit-redis"
import Redis from "ioredis"
const redis = new Redis(process.env.REDIS_URL!)
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
// Shared Redis counter across all server instances:
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
prefix: "rate_limit:",
}),
})
Skip Trusted Clients
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
// Skip rate limiting for:
skip: (req) => {
// Internal service calls with service key:
if (req.headers["x-service-key"] === process.env.INTERNAL_SERVICE_KEY) {
return true
}
// Verified users get higher limits (handled separately):
if (req.user?.tier === "enterprise") {
return true
}
return false
},
// Custom key (default is IP — customize for authenticated routes):
keyGenerator: (req) => {
// Rate limit by user ID if authenticated, fall back to IP:
return req.user?.id ?? req.ip ?? "unknown"
},
})
Complete Production Security 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 Redis from "ioredis"
const app = express()
const redis = new Redis(process.env.REDIS_URL!)
// 1. Security headers:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
})
)
// 2. CORS:
app.use(
cors({
origin: process.env.NODE_ENV === "production"
? ["https://pkgpulse.com", "https://app.pkgpulse.com"]
: ["http://localhost:3000", "http://localhost:5173"],
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
})
)
// 3. Rate limiting:
app.use(
"/api",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
}),
})
)
// Stricter for auth:
app.use(
"/api/auth",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true,
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
prefix: "auth_limit:",
}),
})
)
// 4. Body parsing (after security middleware):
app.use(express.json({ limit: "10kb" })) // Limit body size
// Your routes:
app.use("/api", router)
app.listen(process.env.PORT)
Fastify Equivalents
import Fastify from "fastify"
import fastifyHelmet from "@fastify/helmet"
import fastifyCors from "@fastify/cors"
import fastifyRateLimit from "@fastify/rate-limit"
const app = Fastify()
// Register plugins (Fastify uses plugins, not middleware):
await app.register(fastifyHelmet)
await app.register(fastifyCors, {
origin: ["https://pkgpulse.com"],
credentials: true,
})
await app.register(fastifyRateLimit, {
max: 100,
timeWindow: "15 minutes",
redis: redisClient,
})
Feature Comparison
| Feature | helmet | cors | express-rate-limit |
|---|---|---|---|
| Attack prevented | XSS, clickjacking, MITM | CSRF, unauthorized access | Brute force, DDoS |
| HTTP standard | Security headers | CORS spec | HTTP 429 |
| Configuration complexity | Medium (CSP) | Low | Low-Medium |
| Redis support | ❌ | ❌ | ✅ (rate-limit-redis) |
| Per-route config | ✅ | ✅ | ✅ |
| Fastify support | ✅ @fastify/helmet | ✅ @fastify/cors | ✅ @fastify/rate-limit |
When You Need All Three
Omitting helmet: Your API serves HTML pages that load scripts from CDNs, but without CSP, an attacker who injects content (via XSS or compromised CDN) can run arbitrary scripts in users' browsers.
Omitting cors: Without CORS, your API will either work from any origin (dangerous) or be blocked by browsers for legitimate frontends — there's no safe default.
Omitting rate-limit: Every endpoint is exposed to brute-force attacks, credential stuffing, and resource exhaustion. Even a simple /api/search can be hammered to spike database costs.
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 documentation.