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.
Content Security Policy in Depth
CSP is the most powerful and most frequently misconfigured helmet feature. A naive contentSecurityPolicy: false disables it entirely to avoid breaking things — this is common in tutorials but leaves you fully exposed to XSS. A better approach is to start with helmet's defaults, observe violations using report-only mode, then tighten the policy based on what your application actually needs. The report-uri or report-to directive sends violation reports to an endpoint you control, letting you audit what external resources your pages actually load before enforcing. For Next.js applications, CSP integration requires generating a per-request nonce and injecting it into both the header and the next/script components — Next.js 14+ has built-in nonce support via middleware. The unsafe-inline style directive is common but defeats XSS protection for inline styles; prefer moving inline styles to CSS classes or using hash-based allowlisting.
CORS in Multi-Tenant and Microservice Architectures
CORS configuration becomes complex when your API serves multiple clients: a public documentation site, an authenticated dashboard, a mobile app backend, and internal microservices. The static origin allowlist approach breaks down at scale — instead, store allowed origins in your database or config service and query them dynamically in the CORS origin function. For internal microservice communication where both services are on the same VPC, skip CORS entirely and use IP allowlisting at the network layer instead. A subtle CORS security issue: if you set Access-Control-Allow-Origin: * with credentials: true, the browser will reject the response with a CORS error — wildcard origin and credentials are mutually exclusive. Teams often discover this when adding cookies to an API that previously only used token headers, and the fix requires switching to explicit origin allowlisting.
Rate Limiting Architecture for Distributed Systems
The default in-memory store for express-rate-limit stores request counts in the Node.js process's heap, which means it is per-instance and per-process. This creates a serious problem in horizontally scaled deployments: if you run four application instances behind a load balancer, each instance allows 100 requests per 15 minutes from the same IP — meaning an attacker gets 400 requests per window across your fleet. The Redis store solution distributes the counter across all instances correctly, but introduces a Redis dependency. For teams on Vercel or other serverless platforms, the Upstash rate limit library (@upstash/ratelimit) handles this with sliding window and token bucket algorithms built on top of Upstash Redis's HTTP API, requiring no persistent connection management. The choice of algorithm matters: fixed window rate limiting creates burst opportunities at window boundaries; sliding window is more accurate but slightly more expensive to compute.
Security Header Testing and Auditing
Adding helmet is not a one-time configuration task — security headers require ongoing verification as your application evolves. The Security Headers analyzer at securityheaders.com grades your headers and identifies missing protections. OWASP's ZAP (Zed Attack Proxy) can automatically scan your API for missing security headers and common misconfigurations. For CI integration, the lighthouse-ci tool includes security header checks in its automated audit pipeline. Pay attention to the Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy headers that helmet sets by default — these can break OAuth flows and third-party embeds if not configured carefully. COEP requires all subresources to opt in to cross-origin loading, which breaks legacy CDN assets not served with Cross-Origin-Resource-Policy headers. Disable COEP for routes that embed third-party content.
Express vs Fastify Security Middleware Ecosystem
While helmet, cors, and express-rate-limit are Express-native, the Fastify ecosystem has mature equivalents (@fastify/helmet, @fastify/cors, @fastify/rate-limit) that integrate with Fastify's plugin system and perform significantly better at high request volumes. Fastify's plugin-based architecture means security middleware is applied per-route or per-scope rather than globally, making it easier to have different rate limits for public vs authenticated routes without middleware ordering complexity. For new high-performance APIs in 2026, Fastify's built-in JSON schema validation also reduces injection attack surface by rejecting malformed request bodies before they reach your handlers — a security benefit that complements but does not replace the middleware stack.
Testing Security Middleware in Development
Security middleware configurations need automated testing to prevent regressions. For CORS, write tests that make requests from allowed origins and verify the correct Access-Control-Allow-Origin header appears, then make requests from disallowed origins and confirm they are rejected. Use supertest to make HTTP requests in-process without a live server. For rate limiting in tests, mock the rate limit store or use a test-specific store instance that resets between tests — the default in-memory store retains state across requests in the same test suite, causing flaky tests if you hit the same IP multiple times. For helmet, verify that security-sensitive headers are present in responses to HTML pages and confirm that APIs have appropriate header configurations for their use case. Adding these tests catches common mistakes like accidentally disabling CSP globally, CORS allowing too many origins, or rate limits too permissive to prevent brute-force attacks.
Security Monitoring and Incident Response
Beyond configuration, security middleware generates signals that should feed into your monitoring and alerting stack. Rate limit responses (HTTP 429) from a single IP in burst patterns indicate a potential brute-force attack in progress — alert on elevated 429 rates and have a runbook for blocking IPs at the infrastructure level (WAF, Cloudflare, nginx deny) when application-level rate limiting is insufficient. CORS violations logged server-side (the browser rejects responses, but your server still sees the request) can indicate attackers probing your API from unauthorized origins. helmet's CSP violations (via the report-to directive) indicate either a misconfigured CSP that's blocking legitimate resources, or an active XSS attempt. Treating security middleware as a source of observability signals rather than just configuration transforms it from a passive defense into an active part of your security monitoring posture.
Compare security and middleware packages on PkgPulse →
See also: Express vs NestJS and Express vs Koa, morgan vs pino-http vs express-winston.