Skip to content
Express.js ex security 5 min read

Rate Limiting & Throttling

A public Express endpoint with no usage limits is an open invitation: a single client can hammer your login route to guess passwords, scrape your API thousands of times a second, or simply exhaust your database connections. Rate limiting caps how many requests a given client may make in a time window, returning 429 Too Many Requests once the budget is spent. Combined with throttling — deliberately slowing responses rather than rejecting them — it turns brute-force and denial-of-service attacks from cheap into impractical. This page covers the de facto standard middleware, express-rate-limit, from a basic global limiter through per-route rules, a shared Redis store for multiple instances, and slowing down repeated login attempts.

Installing and configuring express-rate-limit

The express-rate-limit package is a small, dependency-light middleware. Install it and mount it before your routes so every request is counted.

npm install express-rate-limit
const express = require("express");
const rateLimit = require("express-rate-limit");

const app = express();

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // 100 requests per window per IP
  standardHeaders: "draft-7", // RateLimit-* response headers (RFC draft)
  legacyHeaders: false,       // disable old X-RateLimit-* headers
  message: { error: "Too many requests, please try again later." },
});

app.use(limiter); // applies to every route below

app.get("/api/products", (req, res) => {
  res.json({ products: [] });
});

app.listen(3000);

By default the limiter keys each client by IP address using an in-memory store. When a client exceeds max, the middleware short-circuits the request and sends the configured message with a 429 status. The standardHeaders option advertises the remaining budget so well-behaved clients can self-throttle.

Output:

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 412
Retry-After: 412
Content-Type: application/json

{"error":"Too many requests, please try again later."}

Common options

OptionTypePurpose
windowMsnumberLength of the rolling window in milliseconds.
maxnumber / functionMax requests per window per key (or a function returning it).
standardHeadersstring / booleanEmit RFC RateLimit-* headers ("draft-7" recommended).
legacyHeadersbooleanEmit legacy X-RateLimit-* headers.
keyGeneratorfunctionDerive the client key (e.g. API key instead of IP).
skipfunctionReturn true to bypass limiting for a request.
handlerfunctionCustom responder when the limit is hit.
storeobjectBacking store (memory by default; Redis, etc.).

Warning: If your app runs behind a proxy or load balancer, the default IP key will be the proxy’s address, lumping all users into one bucket. Call app.set("trust proxy", 1) so Express reads the real client IP from X-Forwarded-For. Set it to the number of hops you trust, not blindly to true.

Per-route limits

A single global limit is rarely ideal: a read-heavy listing endpoint can tolerate far more traffic than a login or password-reset route. Create separate limiters and attach them only where needed, either inline on a route or mounted on a Router.

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

const router = express.Router();

// Strict limit on authentication
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // only 5 attempts per 15 min per IP
  message: { error: "Too many login attempts. Try again in 15 minutes." },
});

// Generous limit on public reads
const readLimiter = rateLimit({ windowMs: 60 * 1000, max: 300 });

router.post("/login", authLimiter, async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "Invalid credentials" });
  res.json({ token: issueToken(user) });
});

router.get("/feed", readLimiter, async (req, res) => {
  res.json(await loadFeed());
});

module.exports = router;

Because middleware is just a function in the chain, you can stack a global limiter for baseline protection and add a stricter one to sensitive routes — both will run.

Distributed limiting with a Redis store

The default in-memory store counts requests inside a single Node process. Run two instances behind a load balancer and each keeps its own counter, so the effective limit doubles. For any horizontally scaled deployment you need a shared store. Redis is the standard choice via rate-limit-redis.

npm install rate-limit-redis redis
const rateLimit = require("express-rate-limit");
const { RedisStore } = require("rate-limit-redis");
const { createClient } = require("redis");

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  store: new RedisStore({
    // sendCommand wires the limiter to your Redis client
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
});

app.use(limiter);

Now every instance increments the same atomic counter in Redis, so the limit holds across the whole fleet. Redis also survives restarts, meaning a deploy won’t reset every client’s budget.

Slowing brute-force login attempts

Hard rejection isn’t always the best tool. With brute-force password guessing you can frustrate the attacker without ever returning an error by progressively delaying each response. The companion package express-slow-down adds latency once a threshold is crossed.

npm install express-slow-down
const slowDown = require("express-slow-down");

const loginSpeedLimiter = slowDown({
  windowMs: 15 * 60 * 1000,
  delayAfter: 3,        // first 3 requests are full speed
  delayMs: (hits) => hits * 500, // then +500ms per extra request
  maxDelayMs: 10_000,   // cap the penalty at 10 seconds
});

// Combine throttling (slow) with a hard ceiling (reject)
router.post("/login", loginSpeedLimiter, authLimiter, async (req, res) => {
  // ...authenticate as above
});

A legitimate user typing one wrong password notices nothing, while a script firing hundreds of guesses grinds to a crawl long before authLimiter cuts it off entirely. Pairing the two gives a smooth, attacker-hostile curve.

Tip: Key brute-force protection by username plus IP, not IP alone. A shared NAT (office, campus, mobile carrier) puts many users behind one address, and IP-only limits would lock them out together. Use a custom keyGenerator to combine the target account with the client IP.

Best Practices

  • Mount a permissive global limiter for baseline DoS protection, then layer stricter per-route limits on login, signup, and password-reset endpoints.
  • Always configure trust proxy correctly behind a proxy or CDN, or every client collapses into one rate-limit bucket.
  • Use a shared store (Redis) the moment you run more than one instance — in-memory counters silently multiply your limits.
  • Return RFC RateLimit-* headers (standardHeaders: "draft-7") so honest clients can back off before being blocked.
  • Prefer throttling with express-slow-down over hard blocks for login flows, and key by account + IP to avoid punishing shared networks.
  • Never rate-limit by a header the client controls (like a raw API key) without verifying it first — an attacker can rotate it to dodge the limit.
  • Monitor 429 rates: a sudden spike is an early signal of an attack or a misbehaving client.
Last updated June 14, 2026
Was this helpful?