Rate Limiting and Security Headers in Node.js

· 8 min read · Updated March 19, 2026 · beginner
node express security api helmet

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:

  1. Rate limiting — Restricting how many requests a single user can make
  2. 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`. After making 100 requests within 15 minutes, you'll see the error message instead of your response. The response includes these helpful headers:

```text
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1742380800

These headers tell the client how many requests they can still make.

Rate Limiting by User ID

By default, express-rate-limit tracks requests by IP address. Once you add authentication, you might want to limit by user ID instead:

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.

Skipping Requests

You can skip rate limiting for certain requests, like verified webhooks or trusted services:

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';
  }
});

Custom Response Handler

Need more control over the response? Use a handler function:

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)
    });
  }
});

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.

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.

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) — This 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.

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!