Rate Limiting and Security Best Practices in Express.js

Rate Limiting and Security Best Practices in Express.js

I spent a solid week tightening security on an Express app and wrote down everything that tripped me up — rate limiting, security headers, CORS, input sanitization, and cookie hardening. This is that list, dressed up as a blog post.

OK so rate limiting. Everyone says do it, nobody explains the tricky parts. Like what numbers to actually pick, or why the default in-memory store will silently fail you in production, or how one misconfigured CORS header can undo all your other work. I spent a solid week tightening security on an Express app last year and wrote down everything that tripped me up. This is that list, dressed up as a blog post.

Rate Limiting with express-rate-limit

Rate limiting caps how many requests a single client can fire at your server in a given window. Without it, one angry bot can peg your CPU and ruin the experience for everyone else. It also stops brute-force login attempts, which is the attack vector most people forget about until it actually happens.

Install the packages we'll need throughout this post:

npm install express-rate-limit helmet cors express-mongo-sanitize hpp csurf cookie-parser

Here's the global limiter I use as a starting point:

const rateLimit = require('express-rate-limit');

// Global rate limiter: 100 requests per 15 minutes
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true, // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,  // Disable X-RateLimit-* headers
  message: {
    status: 429,
    error: 'Too many requests. Please try again later.'
  },
  skip: (req) => {
    // Optionally skip rate limiting for certain IPs or paths
    return false;
  }
});

app.use(globalLimiter);

Why those numbers? 100 requests per 15 minutes is roughly one request every 9 seconds. That's generous enough for normal users clicking around, but low enough to slow down a script. You'll want to tune this to your traffic -- if you have an SPA that fires 20 API calls on page load, bump the max up or you'll lock out your own users on the first visit.

Login and registration endpoints need something way stricter. Five attempts per 15-minute window. That's it. If someone can't remember their password in five tries, they need the reset flow anyway:

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // Only 5 login attempts per 15 minutes
  message: {
    status: 429,
    error: 'Too many login attempts. Please try again after 15 minutes.'
  },
  skipSuccessfulRequests: true // Don't count successful logins
});

app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);

That skipSuccessfulRequests flag is the detail I missed the first time. Without it, a legitimate user who logs in correctly still burns through their quota. With it, only failed attempts count. That's exactly the behavior you want -- penalize the suspicious activity, not the normal kind.

Now here's the part that bit me in production: the default store is in-memory. That means if you're running three instances behind a load balancer, each one has its own counter. An attacker gets 5 x 3 = 15 attempts. Use Redis:

const RedisStore = require('rate-limit-redis');
const { createClient } = require('redis');

const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();

const productionLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args)
  })
});

app.use(productionLimiter);

If you're not using Redis yet, this might feel like overkill. It isn't. The moment you scale past one process, in-memory rate limiting is basically decorative.

Security Headers with Helmet

Helmet sets a bunch of HTTP headers that tell browsers to turn on their built-in security features. One line gets you most of the way there:

const helmet = require('helmet');

// Apply all default security headers
app.use(helmet());

That single call sets over a dozen headers. Most of them you can leave at defaults and never think about again. The one you will need to customize is Content-Security-Policy, because the defaults are strict enough to break most real apps:

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "https://trusted-cdn.example.com"],
    styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
    imgSrc: ["'self'", "data:", "https://images.example.com"],
    connectSrc: ["'self'", "https://api.example.com"],
    fontSrc: ["'self'", "https://fonts.gstatic.com"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: []
  }
}));

CSP is probably the single most effective header against XSS. It tells the browser exactly which sources are allowed to load scripts, styles, images, and so on. Anything not on the list gets blocked. That means even if an attacker manages to inject a <script> tag, it won't execute unless it's served from one of your whitelisted origins.

HSTS is the other one worth calling out. It forces HTTPS for a set duration, so even if someone types http:// in the address bar, the browser upgrades the connection automatically:

app.use(helmet.hsts({
  maxAge: 31536000,         // 1 year in seconds
  includeSubDomains: true,
  preload: true
}));

The rest -- X-Content-Type-Options (nosniff), X-Frame-Options (clickjacking defense), Referrer-Policy -- are all set by default when you call helmet(). Leave them on. There's rarely a reason to turn them off.

CORS Configuration

CORS is one of those things that seems simple until you get it wrong and spend two hours staring at browser console errors. The short version: it controls which domains can talk to your API from a browser.

const cors = require('cors');

// Define allowed origins
const allowedOrigins = [
  'https://yourfrontend.com',
  'https://www.yourfrontend.com',
  'https://staging.yourfrontend.com'
];

// Add localhost in development
if (process.env.NODE_ENV === 'development') {
  allowedOrigins.push('http://localhost:3000', 'http://localhost:5173');
}

const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (mobile apps, curl, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,             // Allow cookies to be sent
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining'],
  maxAge: 86400                  // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

The mistake I see most often: setting origin: '*' and credentials: true at the same time. Browsers flat-out refuse that combination. If you need to send cookies cross-origin, you must list specific origins. No wildcard.

The maxAge setting matters more than you'd think. Without it, the browser fires a preflight OPTIONS request before every single cross-origin request. At 86400 seconds (24 hours), those preflights get cached and your users stop paying a round-trip penalty on every API call.

Never use a wildcard CORS origin in production. Always whitelist the specific domains that should have access to your API. An overly permissive CORS policy can allow attackers to make authenticated requests from malicious websites.

Input Sanitization and Injection Prevention

Every piece of data that comes from a client -- body, params, query string, headers -- is hostile until proven otherwise. There are three flavors of injection you'll hit in a typical Express + MongoDB stack, and each one needs a different fix.

NoSQL Injection is the sneaky one. An attacker sends {"$gt": ""} as the password field, and suddenly your MongoDB query matches every document. The fix is a single middleware:

const mongoSanitize = require('express-mongo-sanitize');

// Remove any keys containing $ or . from req.body, req.query, req.params
app.use(mongoSanitize({
  replaceWith: '_',
  onSanitize: ({ req, key }) => {
    console.warn(`Sanitized key: ${key} in request to ${req.originalUrl}`);
  }
}));

HTTP Parameter Pollution is weirder. Someone sends ?sort=name&sort=DROP TABLE users and your app might choke trying to process an array where it expected a string. hpp picks the last value and moves on:

const hpp = require('hpp');

app.use(hpp({
  whitelist: ['tags', 'categories'] // Allow duplicates for these parameters
}));

XSS is the classic. CSP headers block most of it on the browser side, but you should still validate inputs on the server. express-validator handles validation and sanitization in one shot:

const { body, param, query, validationResult } = require('express-validator');

// Validate and sanitize login input
const loginValidation = [
  body('email')
    .isEmail()
    .normalizeEmail()
    .withMessage('Valid email is required'),
  body('password')
    .isLength({ min: 8, max: 128 })
    .withMessage('Password must be between 8 and 128 characters')
    .trim()
];

// Validation middleware
function validate(req, res, next) {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      error: 'Validation failed',
      details: errors.array().map(e => ({ field: e.path, message: e.msg }))
    });
  }
  next();
}

app.post('/api/auth/login', loginValidation, validate, loginHandler);

The general rule: validate the shape and type of everything, reject what doesn't match, and never try to "fix" bad input. If someone sends an email field that isn't an email, don't try to extract an email from it. Just return 400 and move on.

CSRF — Cross-Site Request Forgery — is the attack where a malicious site tricks a logged-in user's browser into making requests to your server. The browser dutifully attaches cookies, so your server thinks it's a legitimate request. I once worked on an app where someone demonstrated this by crafting a hidden form on a completely different domain that would change the user's email address. Took about ten lines of HTML. Terrifying.

If you're using cookie-based sessions (and most Express apps do), you need CSRF tokens. The csurf middleware generates a unique token per session that must be included in every state-changing request. A malicious site can trigger a request, but it can't read the token, so the request gets rejected:

const csrf = require('csurf');
const cookieParser = require('cookie-parser');

app.use(cookieParser());
app.use(csrf({ cookie: true }));

// Make the token available to your frontend
app.get('/api/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// The middleware automatically rejects POST/PUT/DELETE
// requests that don't include a valid _csrf token

On the cookie side, there are a few flags you should always set. httpOnly prevents JavaScript from reading the cookie — so even if XSS gets through, the attacker can't steal session tokens. secure ensures the cookie only travels over HTTPS. sameSite limits cross-origin cookie sending, which is another layer against CSRF:

app.use(require('express-session')({
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  },
  resave: false,
  saveUninitialized: false
}));

I forgot httpOnly once on a production app. Found out when a security audit flagged it. The fix was one line, but the embarrassment lasted longer.

The Full Express Security Setup

Here's the full setup I drop into new Express projects. Middleware order matters -- security stuff goes first, before any routes get a chance to run:

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const hpp = require('hpp');
const cookieParser = require('cookie-parser');

const app = express();

// 1. Trust proxy (if behind nginx/load balancer)
app.set('trust proxy', 1);

// 2. Security headers
app.use(helmet());

// 3. CORS
app.use(cors({
  origin: ['https://yourfrontend.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
}));

// 4. Global rate limiting
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true
}));

// 5. Body parsing with size limits
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// 6. Cookie parsing
app.use(cookieParser());

// 7. Data sanitization against NoSQL injection
app.use(mongoSanitize());

// 8. Prevent HTTP parameter pollution
app.use(hpp());

// 9. Stricter limits on auth routes
app.use('/api/auth', rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many auth attempts' }
}));

// 10. Your routes go here
app.use('/api', require('./routes'));

// 11. Global error handler (never leak stack traces)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message
  });
});

app.listen(3000, () => console.log('Server running securely on port 3000'));

A few things to notice. The body parser has a limit: '10kb' -- that stops someone from posting a 500MB JSON blob and eating all your memory. The error handler strips stack traces in production, because those traces contain file paths and line numbers an attacker shouldn't see. And the auth limiter is separate from the global one, intentionally tighter.

I won't sugarcoat this: keeping all of this straight across multiple projects is tedious. I keep a template repo with this exact setup and pull from it every time. It takes five minutes to configure and saves you from a very bad week later.

Security isn't a feature you ship once. It's maintenance.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!