Security Middleware
Harden Express APIs with helmet for security headers, cors for cross-origin policy, and rate limiting for abuse prevention.
Recipe
Quick-reference recipe card - copy-paste ready.
import express from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
const app = express();
app.set("trust proxy", 1);
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGIN ?? "https://app.example.com",
credentials: true,
}));
app.use(express.json({ limit: "1mb" }));
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
}));When to reach for this: Every public-facing API. Security middleware is not optional for production endpoints.
Working Example
import express from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
const app = express();
app.set("trust proxy", 1);
app.use(helmet({
contentSecurityPolicy: false, // enable if serving HTML from Express
crossOriginResourcePolicy: { policy: "cross-origin" },
}));
const allowedOrigins = ["https://app.example.com", "https://staging.example.com"];
app.use(cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
}));
const apiLimiter = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: "Too many login attempts" },
});
app.use(express.json({ limit: "1mb" }));
app.use("/api", apiLimiter);
app.post("/api/auth/login", authLimiter, loginHandler);
app.get("/health", (_req, res) => res.json({ ok: true }));
function loginHandler(_req: express.Request, res: express.Response) {
res.json({ token: "..." });
}What this demonstrates:
helmetsets security headers (X-Content-Type-Options, HSTS, etc.)- Dynamic CORS origin allowlist
- General API rate limit plus stricter auth endpoint limit
- Health check exempt from heavy middleware (mounted before limiter scope)
Deep Dive
How It Works
- helmet: Sets HTTP response headers that mitigate XSS, clickjacking, and MIME sniffing
- cors: Adds
Access-Control-*headers; browsers enforce CORS, server-to-server calls ignore it - rate-limit: Tracks requests per IP (or custom key) in memory or Redis store
Header Cheat Sheet
| Header | Set by | Purpose |
|---|---|---|
Strict-Transport-Security | helmet | Force HTTPS |
X-Content-Type-Options | helmet | Prevent MIME sniffing |
Access-Control-Allow-Origin | cors | Allowed browser origin |
RateLimit-Remaining | rate-limit | Client-visible quota |
Production Rate Limit Store
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const limiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
windowMs: 60_000,
max: 100,
});Gotchas
- CORS
origin: *with credentials - browsers reject this combination. Fix: specify exact origins. - Rate limiter before trust proxy - one bucket for all users. Fix: trust proxy first.
- helmet CSP breaking inline scripts - fine for JSON APIs; breaks if Express serves HTML. Fix: disable CSP or configure directives.
- No rate limit on auth endpoints - brute force login attacks. Fix: strict limiter on
/auth/login. - CORS is not auth - server-to-server and curl bypass CORS entirely. Fix: always validate tokens server-side.
- In-memory rate limit with multiple pods - each pod has its own counter. Fix: Redis store for distributed counting.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| API gateway rate limiting | Kong, AWS API Gateway in front | Simple single-service deploy |
| Cloudflare WAF | DDoS and bot protection at edge | Internal-only API |
Fastify @fastify/helmet | Fastify stack | Express codebase |
NestJS ThrottlerModule | NestJS with decorator limits | Plain Express |
FAQs
Do I need CORS for a mobile app API?
Native mobile apps do not enforce CORS. You still need auth. CORS matters for browser-based clients (SPA, admin dashboard).
Should helmet be used for JSON-only APIs?
Yes. Security headers protect against unexpected content-type attacks and improve security scanner scores.
What rate limit is reasonable?
Start with 100 requests/minute per IP for general API, 10/15min for login. Tune based on real traffic patterns.
How do I exempt webhooks from rate limits?
Mount webhook routes before the limiter or use a skip function that checks the path.
Does cors handle preflight?
Yes. The cors package responds to OPTIONS automatically when configured.
What about CSRF for cookie-based auth?
CORS does not prevent CSRF. Use csurf or SameSite cookies with double-submit tokens for cookie sessions.
Can I use helmet with Fastify?
Yes via @fastify/helmet. Same concepts apply.
How do I test security headers?
curl -sI https://api.example.com/health and verify Strict-Transport-Security, X-Content-Type-Options, etc.
Related
- Middleware Ordering - registration sequence
- Reverse Proxy Awareness - trust proxy for rate limits
- Security Basics - broader security posture
- OWASP Top 10 for APIs - threat model
- Express Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.