Express Rate Limiting: Production Setup, Redis & Proxies

Configure express-rate-limit for production: trust the right proxy, share counters with Redis, set per-route limits, and verify 429s against real traffic.

May 9, 2026·7 min read

Express Rate Limiting: Production Setup, Redis & Proxies

To rate-limit an Express application in production, configure the client IP, choose a store that matches your process count, and set quotas for individual routes. Then verify which requests receive a 429 response and whether legitimate users can still complete their tasks.

This guide covers a basic express-rate-limit setup, trusted proxies, Redis-backed counters, and route-specific limits. The example quotas are starting points to test, not universal production defaults. Check the current library configuration reference for your installed version.

The basic setup

npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100, // 100 requests per minute per IP
  standardHeaders: true,
  legacyHeaders: false,
});

app.use(limiter);

This works for a single Express process serving requests directly. Most production deployments aren't either of those things.

Mistake 1: trusting the wrong IP

By default, express-rate-limit reads the IP from req.ip. This is req.socket.remoteAddress in stock Express — the address of whoever connected to your server. If you're behind a load balancer, CDN, or reverse proxy, that's the proxy's address, not the user's.

Result: every request from any user behind your CDN gets rate-limited as one address. You either set the limit so high it's pointless, or you accidentally rate-limit your entire user base when one user goes over.

The fix:

app.set('trust proxy', 1); // only when every request crosses one trusted proxy

This example fits a verified one-proxy topology. Configure trusted addresses or a trust function when paths differ, and prevent direct access that bypasses the proxy. Express warns that broadly trusting forwarded headers can let clients supply their own IP. See Express behind proxies.

For Cloudflare, trust its client-IP header only when the origin accepts requests exclusively through trusted Cloudflare infrastructure. A public origin must not accept a client-supplied header as proof of identity. Prefer a validated proxy setup and the library's default IP key generator, which handles IPv6 subnet grouping.

Mistake 2: in-memory storage with multiple processes

express-rate-limit's default store is an in-memory MemoryStore. If you run more than one Express process (PM2 cluster mode, Kubernetes pods, multiple servers), each process has its own counter. An attacker hitting your service can spread their requests across processes and effectively multiply their limit by the process count.

The fix: use a shared store. Redis is the standard:

npm install rate-limit-redis ioredis
import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),
});

If your app already uses Redis (most Express apps do for sessions or caching), this is a 5-line addition. If it doesn't, the cost of adding Redis just for rate limiting is real — consider whether infrastructure-layer rate limiting (AWS WAF, Cloudflare) is enough.

Mistake 3: one global limit for all endpoints

A 100/min limit makes sense for /api/users but is way too low for /api/autocomplete and way too high for /auth/login. One global limit is always wrong for one half of your endpoints.

The fix: per-endpoint limits, with stricter limits on auth and write endpoints:

const generalLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
});

const authLimiter = rateLimit({
  windowMs: 5 * 60 * 1000,
  max: 5, // 5 attempts per 5 minutes for auth
  skipSuccessfulRequests: true, // only count failures
});

const autocompleteLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 1000, // legitimate users do hit autocomplete a lot
});

app.use(generalLimiter);
app.post('/auth/login', authLimiter, loginHandler);
app.get('/api/autocomplete', autocompleteLimiter, autocompleteHandler);

skipSuccessfulRequests uses the request-success predicate, which defaults to HTTP status codes below 400. Use it for login attempts only if failed authentication returns an appropriate failure status, or configure requestWasSuccessful from an explicit application outcome. A failed login that returns HTTP 200 would otherwise escape the counter. See the request-success options.

Per-user vs per-IP limiting

For authenticated endpoints, per-user limiting (by user ID) is more accurate than per-IP. A whole office network NAT'd to one IP shouldn't share a single rate limit:

import { ipKeyGenerator } from 'express-rate-limit';

const userLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 200,
  keyGenerator: (req) => req.user?.id
    ? `user:${req.user.id}`
    : `ip:${ipKeyGenerator(req.ip)}`,
});

For unauthenticated endpoints, you have to use IP — there's no other identifier. For authenticated ones, prefer user ID.

Use authenticated, server-verified user IDs for the user key. The IP fallback uses ipKeyGenerator so rotating IPv6 addresses do not each receive a new quota.

What rate limiting doesn't solve

Rate limiting handles volume from a single source. It doesn't handle:

  • Distributed attacks. Many sources, each below threshold. Need different detection (see credential stuffing detection).
  • Bot traffic. Bots can stay under your rate limit and still cause real cost. Block them at the IP layer using reputation feeds — see block bot traffic in Express.
  • Application-layer abuse. Cart cycling, coupon stacking, business-logic attacks. Rate limiting doesn't see them as abuse.

Rate limiting is one layer. The full stack also includes IP-reputation firewall, bot blocking, and behavioral detection.

Verify rate limits with production evidence

Check 429 responses by route and time window, inspect representative traces, and compare the impact on normal requests before tightening a limit. A coding agent can help correlate this evidence with your Express middleware through SecureNow's coding-agent production security workflow. Ask for a scoped recommendation and verification steps before authorizing a change.

What this costs

express-rate-limit is free. Redis costs whatever your hosting charges (often $5–$30/month for a managed instance, $0 if self-hosted). For most Express apps, total rate-limiting cost is under $10/month.

For a fully managed alternative that bundles rate limiting with bot blocking and IP reputation: see the SecureNow Firewall (free for 14 days, then $99 per app per month). The firewall preload handles the IP-reputation layer; pair with express-rate-limit for application-aware limits.

A working production config

Putting it together:

import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import Redis from 'ioredis';

app.set('trust proxy', 1); // verified one-proxy topology only

const redis = new Redis(process.env.REDIS_URL);
const storeFor = (prefix) => new RedisStore({
  sendCommand: (...args) => redis.call(...args),
  prefix,
});

const general = rateLimit({ store: storeFor('rl:general:'), windowMs: 60_000, max: 100 });
const auth = rateLimit({ store: storeFor('rl:auth:'), windowMs: 5 * 60_000, max: 5, skipSuccessfulRequests: true });
const write = rateLimit({ store: storeFor('rl:write:'), windowMs: 60_000, max: 30 });

app.use(general);
app.post('/auth/login', auth, loginHandler);
app.post('/api/orders', write, ordersHandler);

Create a separate store instance and key prefix for each limiter. Reusing one store across limiters can mix counters and initialization settings; see the store-reuse validation guidance. Validate proxy trust, authentication response semantics, quotas, and datastore-failure behavior before deploying this example. A firewall adds another layer; it does not replace those checks.

Frequently Asked Questions

What's wrong with the default express-rate-limit setup?

Defaults need to match your deployment. MemoryStore keeps counters in one process, proxy trust determines the client IP, and each route needs an appropriate quota. Current defaults allow 5 requests per one-minute window; configure your own limit and test legitimate traffic.

Should I use Redis or in-memory?

Redis if you have more than one Express process. In-memory works for single-instance deployments but rate limits become per-process if you scale horizontally — defeating the purpose.

What about distributed rate limiting at the infrastructure layer?

AWS API Gateway, Cloudflare, NGINX — all do rate limiting at higher layers. They're appropriate for coarse limits (1000 req/min/IP). Application-layer rate limiting handles the per-endpoint, per-user nuances.

Can rate limiting break legitimate traffic?

Yes if misconfigured. The two common failures: limiting based on the proxy IP instead of the real client IP, and limiting too aggressively on endpoints that legitimate users hit in bursts (autocomplete, polling). Test with synthetic real-user traffic before deploying.

Recommended reading

Block an IP, a Range or One Route Without a Deploy (Node.js)

Block one IP, a CIDR range, or a single route and method in a Node.js app within seconds, with no code change: setup, scoping, CLI, MCP, verification and undo.

Oct 27
11 Signs Your AI-Built App Needs a Security Audit

Built your app with a coding agent? 11 warning signs it needs a security audit, each checkable in five minutes, and how to run the full audit yourself.

Oct 26
AI Security Audit Part 1: Set Up Your Agent to Audit Code

Part 1 of the AI security audit series: set up an AI coding agent to audit your app locally, connect SecureNow, and run your first threat-model pass.

Jul 24