Skip to main content

helmet vs cors vs express-rate-limit: Node.js Security Middleware (2026)

·PkgPulse Team

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

Featurehelmetcorsexpress-rate-limit
Attack preventedXSS, clickjacking, MITMCSRF, unauthorized accessBrute force, DDoS
HTTP standardSecurity headersCORS specHTTP 429
Configuration complexityMedium (CSP)LowLow-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.

Compare security and middleware packages on PkgPulse →

Comments

Stay Updated

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