In-memory limits, distributed Redis limits, token buckets, and the anti-bypass rules that keep an API alive under brute force, scraping, and honest traffic spikes.
The login endpoint was taking 200 password attempts a minute from a single IP. I knew because the logs told me — a wall of 401 Unauthorized responses arriving in bursts, five every few seconds, from what was clearly a credential-stuffing bot rotating through a leaked password list. The service was small, the API was public, and nothing in the request path knew how to say "enough."
That night I implemented the whole playbook this article covers: in-memory rate limiting first, then distributed limits in Redis, then a token bucket for the bursty endpoints, and finally the anti-bypass rules that stop attackers from walking around all of it. The brute-force wall stopped within the hour. Two years later the same design is holding against scraping campaigns and accidental client loops alike. Here is the full thing, in the order you should build it.
Why Rate Limiting Is Not Optional
A rate limit is a policy that answers one question: how many requests can a caller make in a given window? It is the cheapest protection you can buy — it stops credential stuffing, brute force, scraping, OTP bombing, and the self-inflicted damage of a misconfigured client that suddenly re-syncs 100,000 records through your public API. It also protects your cost: every request that hits your database, your LLM endpoint, or your third-party provider is money, and a runaway loop can burn a month of budget in a day.
The mistake people make is treating rate limiting as one feature. It is three layers: per-IP protection, per-user protection, and per-resource limits. You need all three, because they stop different attackers.
Step 1: Start In-Memory with express-rate-limit
For a single instance, in-memory is the right first move. express-rate-limit is the standard, and it takes five minutes:
npm install express-rate-limit
import express from "express";
import { rateLimit } from "express-rate-limit";
const app = express();
const globalLimiter = rateLimit({
windowMs: 60_000,
limit: 120,
standardHeaders: "draft-8", // X-RateLimit-* headers
legacyHeaders: false,
});
app.use(globalLimiter);
Now every IP gets 120 requests a minute. The headers the library emits — RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset — are what well-behaved clients use to back off, and they are the correct 2026 standard format.
The pitfall: in-memory limits live in one process. The moment you run two instances behind a load balancer, your limit splits in half — or worse, doubles the effective ceiling. In-memory is a staging setup, not a production answer for anything with more than one instance.
Step 2: Lock Down the Sensitive Endpoints Harder
Auth endpoints deserve a tighter limit than the rest of the API. This is the rule that stopped my 200-attempts-a-minute login wall:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: "draft-8",
legacyHeaders: false,
message: { error: "Too many attempts. Please wait." },
});
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
app.use("/api/auth/forgot-password", authLimiter);
Five attempts per fifteen minutes per IP is not user-hostile; it is what makes credential stuffing economically pointless. Even the best botnet slows to a crawl against a per-IP ceiling of five. But note the phrase "per IP" — a single attacker behind a rotating IP pool sails past this, which is exactly why you add the next two layers.
Step 3: Go Distributed — the Limits Live in Redis
Once you have more than one instance, the counter must live somewhere all instances can see. Redis is the shared store, and express-rate-limit has a first-party store for it:
npm install ioredis @express-rate-limit/redis
import { RedisStore } from "@express-rate-limit/redis";
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const distributedLimiter = rateLimit({
windowMs: 60_000,
limit: 120,
store: new RedisStore({ client: redis, prefix: "rl:" }),
standardHeaders: "draft-8",
legacyHeaders: false,
});
Now the 120-per-minute ceiling is global across every instance. A client that rotates across your three boxes hits the same counter and gets the same 429. This is the difference between a limit and a pretend limit.
Why the store matters: if your Redis is unavailable, express-rate-limit with a store will fall back to per-process behavior by default rather than fail open — verify that default in your version, because "fail open under load" is exactly when you want the limiter most.
Step 4: Token Bucket for Bursty Endpoints
Fixed windows have a problem: they punish the legitimate spike. A client that sends 5 requests at the top of a minute and 5 more a minute later looks identical to one that dumps all 10 in the first second. For endpoints where brief bursts are legitimate — webhook delivery, real-time sync — a token bucket is the better model.
The idea: a bucket holds N tokens, each request spends one, and tokens refill at a steady rate. Bursts up to the bucket size pass; sustained traffic is capped by the refill rate. It is about 40 lines to implement over Redis with a Lua script so it is atomic:
import { createClient } from "ioredis";
const redis = new createClient({ url: process.env.REDIS_URL });
const TAKE_TOKEN = `
local tokens = tonumber(redis.call("GET", KEYS[1]) or ARGV[1])
local last = tonumber(redis.call("GET", KEYS[1] .. ":ts") or ARGV[2])
local refill = (ARGV[3] / ARGV[4]) * (tonumber(ARGV[5]) - last)
local bucket = math.min(tokens + refill, ARGV[3])
if bucket >= 1 then
redis.call("SET", KEYS[1], bucket - 1)
redis.call("SET", KEYS[1] .. ":ts", ARGV[5])
return 1
else
return 0
end
`;
async function takeToken(key, capacity, refillPerSec) {
const now = Date.now() / 1000;
const res = await redis.eval(
TAKE_TOKEN, 1, `bucket:${key}`,
capacity, capacity, refillPerSec, now
);
return res === 1;
}
Call it on the bursty route, and return 429 when it returns false. For 90% of APIs the fixed window is enough; reach for the token bucket when a legitimate client actually bursts, because that is the case where a fixed window generates false rejections and angry integrations.
A Note on Window Models: Fixed vs Sliding
While we are here, the window model matters more than most tutorials admit. A plain fixed window in Redis counts requests against a single calendar window, which means a client can fire 120 requests at 11:59:59 and another 120 at 12:00:01 — effectively 240 in two seconds, all within the "limit". Sliding windows close that loophole by counting against a moving window, and the standard implementation uses a sorted set of request timestamps:
const SLIDING_WINDOW = `
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, now - window)
local count = redis.call("ZCARD", KEYS[1])
if count < limit then
redis.call("ZADD", KEYS[1], now, now .. ":" .. ARGV[4])
redis.call("EXPIRE", KEYS[1], window)
return 1
else
return 0
end
`;
async function slidingAllow(key, limit, windowSec) {
const now = Date.now();
return (await redis.eval(
SLIDING_WINDOW, 1, `sliding:${key}`,
now, windowSec * 1000, limit, Math.random().toString(36).slice(2)
)) === 1;
}
The sorted-set approach keeps the count accurate per client but costs more Redis memory per key. My rule: fixed windows for the broad per-IP layer where the loophole is tolerable, sliding windows or token buckets for the sensitive endpoints — auth, payments, and anything that costs real money per call — where the loophole is an actual abuse channel.
Step 5: Per-User and Per-Resource Limits
Per-IP is the floor, but legitimate users behind NAT or shared offices all share one IP — and the reverse, an attacker rotating IPs, is invisible to it. The durable identity is the authenticated user. Add a per-user limiter keyed on the session or API key, with a generous ceiling relative to per-IP:
async function userRateLimit(req, res, next) {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const allowed = await takeToken(`user:${req.user.id}`, 600, 20); // 600 burst, 20/sec
if (!allowed) {
res.set("Retry-After", "1");
return res.status(429).json({ error: "Rate limit exceeded" });
}
next();
}
app.use("/api/sync", userRateLimit);
And per-resource limits for the expensive operations: one password reset per account per hour, a cap on contact-form submissions per account, a limit on LLM calls per user per day. Each expensive resource gets its own key, because the whole point is protecting cost and abuse, and cost is per-resource.
Step 6: Handle the 429 Properly
How you answer a rejected request is part of the security posture. Send the standard headers, set Retry-After, and give a client something it can act on:
app.use("/api", (err, req, res, next) => {
if (err.statusCode === 429) {
res.set("Retry-After", String(Math.ceil(err.retryAfter ?? 1)));
return res.status(429).json({
error: "Rate limit exceeded",
retryAfterSeconds: err.retryAfter ?? 1,
});
}
next(err);
});
A well-formed 429 with Retry-After gets respected by every SDK and most scrapers that are polite enough to check. A naked 500 or a silent drop teaches nobody anything — the honest 429 is how the ecosystem cooperates.
The Anti-Bypass Rules
Rate limiting is defeated in predictable ways. Close these doors or the whole layer is theatre:
-
Never trust
X-Forwarded-Forblindly. If your app reads that header to find the client IP, any caller can sendX-Forwarded-For: 1.2.3.4and rotate it per request — your limiter now counts a single attacker as a thousand clean IPs. Fix it at the proxy: configuretrust proxyto your load balancer only, and have the proxy overwrite the header rather than append. One line of Express config:
app.set("trust proxy", 1); // only trust the immediate proxy — tune to your topology
- Key on the right identity. Per-IP limits alone miss rotating botnets; per-user limits alone miss anonymous scraping. Use both, layered.
- Limit by resource cost, not just request count. A request that costs a database query and one that calls an LLM are not equivalent. Cap the expensive ones tighter.
- Reject early. Put the limiter at the edge of the request pipeline, before body parsing and authentication — wasted CPU and database connections are a DDoS vector too. Auth should come after the cheap check.
- Do not rate-limit yourself. If your own monitoring or webhook callbacks share the same IP path as external traffic, the limiter will happily throttle your own health checks. Whitelist internal traffic explicitly at the proxy, and always verify your limits do not trip the health endpoint that your load balancer depends on — a limiter that 429s your own health probe takes the site down by itself.
Verify It Works Before You Trust It
A rate limit you have never tested is a belief, not a control. Three checks, in order:
-
A scripted burst. Fire 150 requests at your global endpoint in two seconds and confirm the 130th onwards returns
429with aRetry-Afterheader. Do this from a fresh IP so the test does not trip your own monitors. -
The bypass test. Send the same burst with a spoofed
X-Forwarded-Forheader and confirm the limit still holds. If it does not, yourtrust proxyconfiguration is wrong and the entire layer is decorative. - The outage drill. Stop Redis, send traffic, and confirm the limiter fails closed or degrades to per-process limits instead of opening the floodgates. Write down what happens, because "Redis is down" is not an exotic scenario — it is a Tuesday.
Pitfalls I Have Seen in Production
- In-memory limits behind multiple instances. The ceiling silently doubles with every box. Move to Redis before you scale.
- Redis as a single point of failure. If Redis dies, some stores fail open — a live limiter must fail closed or degrade to per-process limits. Test the outage.
-
Wrong IP source. Reading
X-Forwarded-Forbefore you configuretrust proxyhands attackers the bypass. - Fixed windows on bursty clients. Legitimate spikes get falsely rejected; the integration "works in staging, 429s in production". Use a token bucket for those routes.
- No limit on anonymous endpoints. Public, unauthenticated endpoints (search, autocomplete, webhooks) get scraped first. They get limits too.
-
429handled as an error, not a contract. SendRetry-After; well-behaved clients will honor it.
The Production Checklist
- [ ] Global per-IP limiter on all routes
- [ ] Tighter limiter on auth, password reset, and registration
- [ ] Redis-backed store for any multi-instance deployment
- [ ] Token bucket for bursty or expensive endpoints
- [ ] Per-user limit keyed on session/API key, per-resource limits on costly ops
- [ ]
trust proxycorrectly configured;X-Forwarded-Fornever trusted blindly - [ ]
429withRetry-Afterand rate-limit headers on every rejection - [ ] Limiter runs early in the pipeline, before auth and body parsing
- [ ] Redis outage tested — confirm fail-closed or per-process degradation
- [ ] Monitoring: rate-limit hits, 429 rate, and top blocked keys per day
The login endpoint that was taking 200 password attempts a minute now answers five failures in fifteen minutes and then goes quiet — the bot moved on to an easier target, which is the whole game. Rate limiting is not glamorous, but it is the difference between an API that survives brute force, scraping, and honest traffic spikes, and one that bleeds money and trust while you sleep. Build it in the order above, test the failure modes, and the wall holds.
*Gulshan Yad
Top comments (0)