Rate Limiting and Security Headers in Node.js
Why your Node.js API needs protection
Imagine you open a restaurant. Everything runs smoothly until someone walks in and orders 10,000 meals at once. Your kitchen crashes, other customers leave, and your business stops. This is exactly what happens to unprotected APIs: bad actors can overwhelm your server with requests, steal passwords through repeated login guesses, or simply crash your service.
This tutorial shows you two essential tools to protect your Node.js applications:
- Rate limiting: restricting how many requests a single user can make
- Security headers: HTTP instructions that tell browsers how to behave safely
By the end, you’ll have a secure Express API that handles traffic responsibly.
Installing the protection tools
Before writing code, you need two popular npm packages. Open your terminal and run:
npm install express-rate-limit helmet cors
Here’s what each package does:
- express-rate-limit: counts how many requests come from each user and blocks excess traffic
- helmet: sets security-related HTTP headers automatically
- cors: controls which websites can talk to your API
Your first rate limiter
Rate limiting works like a bouncer at a club who counts how many times you’ve entered. Once you hit the limit, you wait until the counter resets.
Create a file called server.js and add this code:
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
// Create a rate limiter - allows 100 requests per 15 minutes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes in milliseconds
limit: 100, // maximum 100 requests per window
standardHeaders: 'draft-7', // use standard RateLimit-* headers
legacyHeaders: false, // disable old X-RateLimit-* headers
message: 'Too many requests, please try again later.'
});
// Apply the limiter to all /api routes
app.use('/api/', apiLimiter);
// A simple API endpoint
app.get('/api/hello', (req, res) => {
res.json({ message: 'Hello, world!' });
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Run the server with node server.js. All requests are tracked against the IP address by default, and the rate limit window slides continuously. After making 100 requests within any 15-minute window, you receive the error message instead of your response. The standard draft-7 headers in the response give the client precise information about its remaining quota:
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1742380800
These headers let the client adapt its request rate without polling the server unnecessarily. Checking the RateLimit-Remaining value before sending a request avoids wasted 429 responses and keeps the client within its budget.
Rate limiting by user ID
By default, express-rate-limit tracks requests by IP address, which works for anonymous traffic but falls apart when multiple users share a single IP behind a corporate network or VPN. Once you add authentication, you should limit by user ID instead so each account gets a fair share:
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
// Use user ID from session/token instead of IP
keyGenerator: (req) => {
return req.user ? req.user.id : req.ip;
}
});
This approach counts requests per authenticated user rather than per IP address. The keyGenerator runs on every request, so keep it cheap. If the user object is loaded from a database on each request, the generator still only needs to read the id property, which is already in memory.
Skipping requests
Not every request should be counted against the rate limit. Webhooks from trusted services and health-check endpoints from your load balancer need a way through. The skip option lets you exclude specific requests based on any property of the incoming request:
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
// Skip rate limiting for webhooks with valid signature
skip: (req) => {
return req.headers['x-webhook-signature'] === 'valid-signature';
}
});
The skip function returns a boolean. Returning true means the request is not counted and not limited. Make sure the signature or header you check cannot be forged. A simple static token is fine for internal services, but public-facing skip conditions need cryptographic verification.
Custom response handler
The default error message works for simple use cases, but a JSON API needs structured error responses. The handler option replaces the default behavior entirely, giving you control over the status code and response body:
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
handler: (req, res, next, options) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(options.windowMs / 1000)
});
}
});
Setting retryAfter to the window duration (in seconds) tells the client exactly when their quota resets. This is useful for building retry logic on the client side without hard-coding a fixed delay.
Different limits for different routes
Not all endpoints need the same protection. Your login page needs stricter limits because attackers might try to guess passwords. Your public data endpoints can be more relaxed. Creating separate limiter instances for different route patterns lets you tune the constraints to the actual risk:
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
// Relaxed limit for regular API routes - 100 requests per 15 minutes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false
});
// Strict limit for login - only 5 attempts per minute
const loginLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window
limit: 5, // only 5 attempts
standardHeaders: 'draft-7',
legacyHeaders: false,
message: 'Too many login attempts, please wait a minute.'
});
// Apply to different routes
app.use('/api/', apiLimiter);
app.post('/auth/login', loginLimiter);
app.post('/auth/login', (req, res) => {
// Your login logic here
res.json({ success: true });
});
app.get('/api/data', (req, res) => {
res.json({ data: 'some information' });
});
This setup means attackers can’t easily brute-force passwords, but regular API users won’t notice any limits.
Adding security headers with Helmet
HTTP security headers are like safety instructions your server sends to every browser. They tell the browser things like “don’t let other websites embed your content” or “only load scripts from your own domain.”
const express = require('express');
const helmet = require('helmet');
const app = express();
// helmet() enables several security headers at once
app.use(helmet());
app.get('/', (req, res) => {
res.send('Hello with security headers!');
});
app.listen(3000);
With just that one line, your server now sends headers like:
- Strict-Transport-Security — Forces browsers to use HTTPS
- X-Content-Type-Options — Prevents browsers from guessing the wrong file type
- X-Frame-Options — Stops other sites from embedding your page (prevents clickjacking)
Configuring individual headers
You can configure individual headers if you need more control:
const helmet = require('helmet');
// Configure HSTS - forces HTTPS for 1 year
app.use(helmet.hsts({
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true, // also apply to subdomains
preload: true // allow preload lists
}));
// Prevent clickjacking - deny all iframe embedding
app.use(helmet.frameguard({
action: 'deny'
}));
// Stop browsers from guessing content types
app.use(helmet.contentTypeSniffing({
mode: 'nosniff'
}));
// Hide that your server runs Node.js
app.use(helmet.hidePoweredBy());
Each header protects against specific attacks. For example, X-Frame-Options: DENY prevents hackers from hiding your page inside an invisible iframe and tricking users into clicking something they didn’t want to.
Content security policy
The Content-Security-Policy header is one of the most powerful security headers. It controls which resources the browser is allowed to load:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // only allow scripts from your domain
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://your-cdn.com'],
connectSrc: ["'self'", 'https://api.yourapp.com'],
fontSrc: ["'self'"],
objectSrc: ["'none'"], // no plugins allowed
upgradeInsecureRequests: []
}
}));
This configuration prevents cross-site scripting (XSS) attacks by only allowing scripts from your own domain.
Controlling who can access your API
Browsers have a security feature called CORS (Cross-Origin Resource Sharing). It stops malicious websites from stealing data by blocking their requests to your API. Unless you explicitly allow them.
const cors = require('cors');
const express = require('express');
const app = express();
// Allow all origins (development only!)
app.use(cors());
// For production, specify exactly which origins are allowed
app.use(cors({
origin: 'https://your-frontend.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
app.get('/api/data', (req, res) => {
res.json({ data: 'secure data' });
});
Never use origin: '*' in production with credentials: true. Browsers will block that combination. Always specify exact domains, and if your API serves multiple frontends, build a simple origin check instead of reaching for a wildcard.
Putting it all together
Here’s a complete example combining rate limiting, security headers, and CORS:
const express = require('express');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const cors = require('cors');
const app = express();
// 1. Rate limiting first (reject bad actors early)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false
});
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Too many login attempts' }
});
// Apply limiters to routes
app.use('/api/', apiLimiter);
app.post('/auth/login', loginLimiter);
// 2. Security headers
app.use(helmet());
// 3. CORS last (set in production via environment variable!)
app.use(cors({
origin: process.env.ALLOWED_ORIGIN || 'http://localhost:3000', // Set in production!
credentials: true
}));
// Your routes
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
app.post('/auth/login', (req, res) => {
res.json({ success: true });
});
app.listen(3000, () => {
console.log('Secure server running on port 3000');
});
The order matters: rate limiting first to reject bad actors early, then security headers, then CORS. This ensures headers are set before any response is sent.
What not to do
Some older security practices are now harmful or useless:
- X-XSS-Protection: modern browsers ignore this. Use Content-Security-Policy instead.
- Public-Key-Pins (HPKP) was caused more problems than it solved and is deprecated.
- Wildcard CORS with credentials: browsers block this. Use specific domains.
- Rate limiting without standard headers: always use
standardHeaders: 'draft-7'.
Focus on modern browsers and current best practices.
Scaling beyond one server
The examples above store rate limits in memory. If you run multiple servers behind a load balancer, you need shared storage. Redis is the common choice:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redisClient = new Redis();
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new RedisStore({
// @ts-expect-error - known issue with TypeScript
sendCommand: (...args) => redisClient.call(...args)
})
});
This lets multiple servers share the same rate limit counter.
Tune protection to the route
Security settings work best when they match the risk of the endpoint. A login route deserves stricter limits than a public read-only endpoint, and a webhook may need a different rule set from both. Thinking at the route level keeps the protection practical instead of generic. It also makes the configuration easier to explain because each rule maps to a real request pattern the service expects.
Keep the response behavior clear as well. If the API rate limits a client, the message should tell them how long to wait or what happened next. If a header is there to protect the browser, the surrounding code should make that purpose visible. That clarity helps both operators and users understand why the server behaved the way it did.
Keep feedback understandable
Security controls work best when the app tells the truth about what happened. A client that hits a limit should get a clear status code and a message that explains the next step. A browser header should also point to a specific risk, not feel like a vague switch you copied from a checklist. That kind of feedback is useful during development and just as useful when you are debugging a real issue in production.
It also helps to keep the logs readable. If a request was blocked, the reason should be easy to find without guessing which middleware acted first. A small amount of structure in the response and the server log makes later maintenance much easier, especially when several protections work together on the same route.
Where to go next
You now have a secure foundation. Here are topics to explore next:
- Authentication with JWT: to identify users beyond IP addresses
- Input validation: using libraries like Zod or Joi to sanitize user data
- Rate limit dashboards: monitoring who is hitting your API
Check the express-rate-limit documentation and Helmet documentation for more options.
Your API is now protected against common attacks. Great job!