Rate Limiting, CORS, and Secure API Headers
I walk through rate limiting, CORS, and Helmet security headers for Express — covering fixed windows, preflight handling, and the 13 headers Helmet sets by default.

You build an API, wire up the routes, write the error handler, and push it live. It responds correctly to every request you throw at it. What you probably haven’t touched yet: how many requests a single IP can make before your server cares, which browser origins are allowed to read your responses, and what your response headers reveal — or fail to protect — about your app.
These three aren’t exotic security features. They’re the baseline. And the three packages that handle them — express-rate-limit, cors, and helmet — take roughly thirty lines to wire together.
Quick answer: Install
express-rate-limit,cors, andhelmet. Registerhelmet()first (security headers on every response), thencors()(so OPTIONS preflight requests aren’t rate-limited), then your rate limiter. Apply a tighter limiter on sensitive routes like/auth/loginon top of the global one.
On this page
- Rate limiting: fixed window by default
- Fixed window versus sliding window
- CORS: what it is and what it isn’t
- Helmet: 13 response headers in one call
- Wiring all three into Express
- What breaks when you get this wrong
- The minimum you owe every deployed API
Rate limiting: fixed window by default
Install the package:
npm install express-rate-limit
Then create a limiter. Here’s a reasonable starting point for a general API:
import { rateLimit } from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
limit: 100, // max 100 requests per window per IP
standardHeaders: 'draft-7', // single RateLimit header with limit, remaining, reset
legacyHeaders: false, // drop the old X-RateLimit-* headers
message: { error: 'Too many requests. Try again shortly.' },
});
app.use('/api', apiLimiter);
Three things worth knowing about this configuration.
The limit option replaced max in v7, though max still works for backward compatibility. Setting limit: 0 doesn’t disable the limiter — it blocks every request. That changed from the older behavior, so check the version you’re on if you’re upgrading.
standardHeaders: 'draft-7' sends a combined RateLimit header containing the limit, remaining count, and reset time. Clients can parse this more reliably than the older X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset triplet. legacyHeaders: false prevents the old headers from appearing alongside the new one.
If your app sits behind a reverse proxy — nginx, a load balancer, Cloudflare — req.ip returns the proxy’s IP, not the client’s. Add this before creating any limiter:
app.set('trust proxy', 1);
With trust proxy set, Express reads X-Forwarded-For and uses the first IP in the chain. Without it, your rate limiter sees every user as the same client and throttles everyone simultaneously.
Fixed window versus sliding window
rateLimit() uses a fixed window by default. Each client gets a counter that starts on their first request and resets after windowMs milliseconds. Simple and low-overhead.
The one limitation: a client can exhaust the full limit right before a window resets and immediately send the same number again at the start of the next window, doubling their effective rate at the boundary. For most legitimate traffic this doesn’t matter. Abuse patterns that deliberately target this gap are less common than the simpler volume-based load that fixed windows handle well.
A sliding window tracks the timestamp of each request and counts only those within the last windowMs milliseconds — no reset boundary to exploit. The trade-off is that sliding windows need an external store to record per-request timestamps. The default in-memory store doesn’t support this; you’d switch to something like rate-limit-redis with a Redis backend.
For most early-stage or internal APIs, the fixed window default is fine. The in-memory store also resets when the process restarts, and in a multi-process deployment each process has its own counter, so if you’re clustering or running multiple instances you’ll need an external store regardless.
CORS: what it is and what it isn’t
Here’s the thing that trips up nearly every developer configuring CORS for the first time: the cors package doesn’t block requests. It sets response headers. The browser reads those headers and decides whether JavaScript on the page is allowed to read the response. A request from curl, Postman, or any server-side HTTP client completely ignores CORS.
CORS is a browser mechanism that protects your users, not access control that protects your API. Use authentication and authorization for that.
The same-origin policy is what CORS relaxes. By default, a browser won’t let JavaScript on https://app.yourdomain.com read a response from https://api.anotherdomain.com. The Access-Control-Allow-Origin response header is the server’s way of saying this origin is allowed to read this response.
Simple requests — GET or HEAD, or POST with a content type of text/plain, application/x-www-form-urlencoded, or multipart/form-data — don’t trigger a preflight. The browser sends the request directly and checks the response header.
Complex requests do. Any DELETE, PUT, or PATCH, any request with Content-Type: application/json, and any request with a custom header like Authorization will first send an OPTIONS preflight. Express ignores OPTIONS by default, which is why CORS errors so often show up on DELETE even when GET works fine.
Install the package:
npm install cors
A production configuration that restricts to known origins:
import cors from 'cors';
const corsOptions = {
origin: ['https://app.yourdomain.com', 'https://admin.yourdomain.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
};
app.use(cors(corsOptions));
One hard constraint: when credentials: true, you cannot use origin: '*'. If the response carries a wildcard origin header and the request included cookies or an Authorization header, the browser will reject it with a CORS error. You must name an explicit origin.
Calling app.use(cors(corsOptions)) as an application-level middleware also handles OPTIONS preflight automatically — you don’t need a separate app.options('*', cors()) call.
Helmet: 13 response headers in one call
Install the package:
npm install helmet
One line adds thirteen security response headers:
import helmet from 'helmet';
app.use(helmet());
The headers worth knowing about:
Content-Security-Policy is the most important one and the most likely to break your app. The Helmet default restricts scripts, styles, fonts, and images to your own origin. Any asset loaded from a CDN is blocked. Check your browser console immediately after adding Helmet — CSP violations show up there in plain text. Most apps need to customize this:
app.use(helmet({
contentSecurityPolicy: {
directives: {
'script-src': ["'self'", 'cdn.yourdomain.com'],
'img-src': ["'self'", 'data:', 'assets.yourdomain.com'],
},
},
}));
Strict-Transport-Security (HSTS) tells browsers to only connect over HTTPS, with max-age=31536000 — one year. Once a browser caches this header it won’t try plain HTTP for that domain for the full duration. Confirm your HTTPS setup is working before enabling HSTS in production. This is one of the few configurations that’s difficult to recover from if applied prematurely.
X-Content-Type-Options: nosniff prevents browsers from guessing the MIME type of a response. Without it, a browser might execute a response as JavaScript even though the server declared a different content type.
X-Frame-Options: SAMEORIGIN prevents your app from loading inside an iframe on another domain, blocking clickjacking. The frame-ancestors CSP directive supersedes this, but Helmet sends both for older browser coverage.
The one that surprises people: Helmet sets X-XSS-Protection: 0, intentionally disabling the browser’s built-in XSS auditor. That auditor has documented vulnerabilities in older browsers — disabling it is deliberately safer than leaving it on.
Wiring all three into Express
Register them at the top of your middleware stack, before routes:
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(corsOptions));
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
});
app.use('/api', apiLimiter);
app.use(express.json());
// routes below
The order matters. helmet() goes first so security headers appear on every response, including 4xx and 5xx errors returned before a route runs. cors() goes before the rate limiter so OPTIONS preflight requests aren’t counted against a client’s quota — browsers send preflights automatically, and blocking them leaves clients unable to make the subsequent real request.
For routes that deserve stricter limits, stack a second limiter directly:
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
message: { error: 'Too many login attempts. Try again later.' },
});
app.post('/auth/login', loginLimiter, authController.login);
Check this before moving on
-
app.set('trust proxy', 1)is set before any rate limiter when behind a proxy -
cors()is registered before the rate limiter - The CSP
script-srcallows every script source your app actually loads -
origin: '*'is not paired withcredentials: true - HSTS is not enabled until HTTPS is working end-to-end on the domain
What breaks when you get this wrong
Skipping trust proxy is a quiet failure. Every request looks like it comes from the proxy’s IP. Your rate limiter throttles all users simultaneously when the first one hits the limit. The API keeps working — just not for anyone.
Using origin: '*' with credentials: true produces a CORS error that looks like a generic network failure in application code. The only visible indication is in the browser console. It’s easy to spend time chasing this on the wrong side of the stack before finding it.
Enabling HSTS before your HTTPS is ready is the hardest mistake here to recover from. A browser that’s seen the header won’t try plain HTTP for a year. Confirm certificates, redirects, and the HTTPS path all work before adding HSTS to your Helmet configuration.
Calling helmet() without customizing CSP silently blocks external assets. Your app appears broken with no obvious route error. Open the browser console, read the CSP violation message, then add the offending source to the correct directive.
Using the default in-memory store in a multi-process deployment means each process tracks its own counter independently. A four-process cluster accepts up to 400 requests per window instead of 100. If your traffic justifies clustering, set up an external store.
The minimum you owe every deployed API
Deferring security configuration until “after launch” is understandable when you’re focused on shipping features. The problem is that rate limiting, CORS, and security headers are the conditions under which it’s safe to expose an endpoint to the internet, not polish you add afterward.
Together, helmet(), cors(), and rateLimit() are three packages and about thirty lines of code. The configuration decisions are small and reversible — except HSTS, which is why that one goes last.
If your error handling doesn’t own the 429 responses these produce, see my guide to centralized error handling in Node.js. And if middleware registration order still feels fuzzy, the Express middleware guide explains why position in the chain determines what each function can see and control.