Skip to main content

helmet vs cors vs express-rate-limit: Express Security Middleware (2026)

·PkgPulse Team

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

Featurehelmetcorsexpress-rate-limit
Attack typeXSS, clickjacking, MIMECSRF, cross-originBrute force, DoS
Config complexityLowMediumLow-Medium
Required for all APIs?✅ Yes✅ Usually✅ Yes
Distributed supportN/AN/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.

Compare security and middleware packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.