Rate limiting a Node.js API across many instances, without letting a forged header ban your customers
A per-process rate limiter stops counting the moment you run a second copy of your API. Moving the counter into Redis fixes that, and exposes the question that matters more: who is the client? Here is how @imqueue/http-protect counts and bans across every instance, what the counter really measures, why trusting X-Forwarded-For lets anyone get your customers banned, and how to set it up so it can't. Every behaviour is measured.
Somebody is hammering your login endpoint. You add a rate limiter, the kind that keeps a counter per IP address in memory, and it works on your laptop.
Then it goes to production, where the API runs as three copies behind a load balancer. Each copy keeps its own counter, so a client that should be stopped after 100 requests gets 300 before anything notices. And when the third copy is added next month, the limit quietly becomes 400.
The fix for that is well known: keep the counter in one place that every copy
shares, and Redis is the usual place. This post shows how to do that with
@imqueue/http-protect, and then spends most of
its time on the question that turns out to matter more: who is the client?
Get that wrong and a rate limiter stops being protection. It becomes a way for
a stranger to have your customers blocked.
Everything below was measured with @imqueue/http-protect 3.0.3, Express
5 and Redis 8 on Node.js 24: three gateway instances sharing one Redis, and
clients sending real HTTP requests.
The short version
- One counter for every instance. Three gateways, one client, 300 requests: exactly 100 served, then refused. It doesn't matter which instance answers.
- The counter measures a stream, not a rate. It is forgotten only after a
full
ttlof silence. A client sending a quarter of the nominal rate, but never pausing, was still refused. - The client's address is the whole security model. By default it comes
from
X-Forwarded-For, which any client can set. A forged header got a customer's address banned. - Trust the header only from your own proxy, and write addresses one way:
Node reports IPv4 clients as
::ffff:a.b.c.d. - Bans are permanent until you remove them from Redis yourself.
- Decide what happens when Redis is down. Out of the box, a request waited 10.4 s and then failed.
How it decides
For every request, HttpProtect makes one decision in a fixed order:
| step | outcome |
|---|---|
| the client's address can't be determined | 429, Redis is not touched |
the address is in safeNetworks |
served, Redis is not touched |
| the address is on the block list | 418 |
the counter passes banLimit |
added to the block list, 418 |
the counter passes maxRequests |
429 |
| otherwise | served |
The counter lives in Redis under imq:http-protect:<address>, and the block
list is one Redis set, imq:http-protect:block-list. Every instance pointed at
the same Redis reads and writes the same keys. That is the entire trick behind
sharing the limit.
429 Too Many Requests is temporary: the client recovers by going quiet.
418 is the ban.
Recipe 1: one counter for every instance
The smallest working setup, for an Express gateway:
import express from 'express';
import HttpProtect from '@imqueue/http-protect';
const protect = new HttpProtect({
redisOptions: { host: 'redis.internal', port: 6379 },
ttl: 10,
maxRequests: 100,
banLimit: 250,
});
const app = express();
app.use(protect.jsonMiddleware()); // first, before body parsing
Mount it before anything else, so a refused request costs as little as possible. Every instance gets the same code and the same Redis.
We ran three instances and sent one client's requests to them in turn, 300 in all:
| requests | answer |
|---|---|
| 1 – 100 | 200 |
| 101 – 250 | 429 |
| 251 – 300 | 418 |
The limits were exact, as if there were one gateway. With an in-memory counter, each instance would have allowed its own 100.
Don't run this test the way it's written here, with these small numbers, in a staging environment people share: request 251 banned our client for good. Recipe 5 explains why.
Recipe 2: read the counter as a stream, not a window
Most rate limiters count requests per fixed period: "100 requests per minute",
with the count starting again every minute. This one doesn't. Its counter has a
time-to-live of ttl seconds, and every request resets that clock. The
count is only forgotten after ttl seconds in which the address sent nothing.
That makes it a limit on how long a client may keep talking without a pause, not on how fast it talks. Two measurements show the difference:
| settings | client behaviour | first refused |
|---|---|---|
maxRequests: 20, ttl: 10 |
2 requests a second | request 21 |
maxRequests: 5, ttl: 3 |
1 request every 2.5 s | request 6 |
The second client sent about a quarter of what "5 per 3 seconds" sounds like it
allows. It was refused anyway, because a 2.5 s gap never reached the 3 s needed
to reset the counter. In both cases, a pause longer than ttl brought the
client straight back.
So choose the numbers as two questions, not one:
ttl: how long does a normal user pause? Pick a value shorter than that. A user reading a page for 15 seconds resets the counter ifttlis 10.maxRequests: how many requests can a normal user send without such a pause? A page that fires 30 API calls when it opens needs headroom well above 30.
The defaults are ttl: 10, maxRequests: 200 and banLimit: 1000. They can
also be set through HTTP_PROTECT_TTL, HTTP_PROTECT_MAX_REQUESTS and
HTTP_PROTECT_BAN_LIMIT.
Recipe 3: decide who the client is
Everything above is keyed by one string: the client's address. Where that string comes from is the most important setting in this post.
By default, HttpProtect uses the request-ip package, which reads the usual
proxy headers first, X-Forwarded-For among them. That is convenient behind a
load balancer, which sets the header. The trouble is that a client can set it
too. We tried both things a client can do with it.
Dodge the limit. A client that sent a different X-Forwarded-For value on
every request got all 1,000 of its requests through, and left 250 separate
counters in Redis.
Ban somebody else. This one is worse. A client sent 260 requests with
X-Forwarded-For: 203.0.113.7, the address of a real customer. The limiter
counted them against the customer, and request 251 put 203.0.113.7 on the
block list. The customer's next request, arriving properly through the load
balancer, got 418. The attacker was never blocked at all.
Simply ignoring the header is not the answer either. Behind a load balancer, the connection always comes from the load balancer, so every client would share one counter, and the first busy minute would ban the load balancer itself.
The right rule is: believe the header only when it was added by your own
proxy. Express does this with its trust proxy setting, and req.ip then
returns the right address:
const app = express();
// the load balancer's address, and nothing else
app.set('trust proxy', ['10.0.0.10']);
const protect = new HttpProtect({
redisOptions: { host: 'redis.internal', port: 6379 },
getClientIp: req =>
req.ip?.replace(/^::ffff:/, '') ?? null,
});
Outside Express, the
proxy-addr package does the same
job, and it's what Express uses internally.
We repeated the attack against this setup. The proxy was 127.0.0.1 and the
attacker connected directly from 127.0.0.2:
| who | what they sent | counted as | result |
|---|---|---|---|
| attacker | 260 requests, forged 203.0.113.7 |
127.0.0.2 |
banned itself |
| the customer, through the proxy | 3 requests | 203.0.113.7 |
all served |
| another customer, through the proxy | 150 requests | its own address | limited at 101 |
The forged header was simply ignored, because it didn't come from the proxy.
The replace(/^::ffff:/, '') in that resolver matters too, and the next recipe
shows why.
Recipe 4: exempt your own traffic
Health checks, monitoring and your own services shouldn't be rate limited.
safeNetworks takes a list of networks that are never counted:
const protect = new HttpProtect({
// ...
safeNetworks: ['10.0.0.0/8', '192.0.2.15/32'],
});
Three things we found while testing it:
Every entry needs a prefix length. A single host is /32 (or /128 for
IPv6). A bare 192.0.2.15 makes the constructor throw, with the unhelpful
message The number NaN cannot be converted to a BigInt.
Node writes IPv4 addresses two ways. A Node server listening on its default
address accepts IPv4 and IPv6 together, and reports an IPv4 client as
::ffff:127.0.0.1. With the default resolver, 127.0.0.1/32 in safeNetworks
exempted nothing: our health check was counted and then banned like anyone
else. Listing the ::ffff: form as well made it work. Stripping the prefix in
getClientIp, as in Recipe 3, is the cleaner fix: it gives every client one
address, one counter and one entry on the block list.
With the default resolver, the exemption is open to anyone. We listed
10.0.0.0/8 and sent 1,000 requests claiming X-Forwarded-For: 10.0.0.5 from
outside. All 1,000 were served, and none was counted. A safe list is only safe
when the address it is checked against can't be chosen by the client. With the
Recipe 3 resolver, the same trick was counted against the attacker's real
address.
Exempt addresses have one more useful property: they never touch Redis, so they are still served when Redis is down. Your health checks keep passing during a Redis outage. Whether that is what you want from a health check is worth deciding on purpose.
Recipe 5: a ban is permanent
The block list is a Redis set with no expiry, and nothing in the package
removes an entry. We banned a client, waited longer than ttl, and tried
again: still 418, even though its counter had expired.
Lifting a ban is one Redis command:
redis-cli SREM imq:http-protect:block-list 203.0.113.7
The very next request from that address was served.
That makes banLimit a decision about when a human should look, not about
traffic shaping. Set it well above anything a real client could reach, and
remember from Recipe 2 that a steady client keeps counting: at the default
banLimit of 1,000, a client sending one request a second without a 10-second
pause reaches it in under 17 minutes.
To see what is banned, read the set directly with SMEMBERS. The package's own
isBanned() check is exact for IPv4 but can report false positives for IPv6
addresses, as its documentation notes.
Recipe 6: tell clients when to come back
The built-in middlewares send the status code and an error body, and no
Retry-After header. A well-behaved client, or your own front end, can only
guess how long to wait.
verify() returns the same decision the middleware uses, so adding the header
takes a few lines:
import HttpProtect, {
VerificationStatus,
} from '@imqueue/http-protect';
app.use(async (req, res, next) => {
const { status, httpCode } = await protect.verify(req);
if (status === VerificationStatus.SAFE) {
return next();
}
if (httpCode === 429) {
res.set('Retry-After', String(protect.ttl));
}
res.status(httpCode).json({ error: { code: httpCode } });
});
The response then carried Retry-After: 10. The value is ttl because that is
exactly what the client needs: ttl seconds of silence. Call verify() once
per request: each call counts.
Recipe 7: decide what happens when Redis is down
We stopped Redis and sent a request:
| setup | answer | after |
|---|---|---|
| default Redis options | 500 |
10.4 s |
enableOfflineQueue: false |
500 |
0.01 s |
| that, plus the middleware below, "open" | 200 |
0.01 s |
| that, plus the middleware below, "closed" | 503 |
0.01 s |
By default the Redis client holds the command in memory and retries it 20 times before giving up. Every request waited ten seconds, which is worse than either serving it or refusing it. Turning off the offline queue makes the failure immediate, and the gateway recovered on its own once Redis was back:
const protect = new HttpProtect({
redisOptions: {
host: 'redis.internal',
port: 6379,
enableOfflineQueue: false,
},
});
app.use(async (req, res, next) => {
try {
const { status, httpCode } = await protect.verify(req);
if (status === VerificationStatus.SAFE) {
return next();
}
res.status(httpCode)
.json({ error: { code: httpCode } });
} catch {
next(); // fail open: serve while the limiter is blind
}
});
Whether to fail open or closed depends on what the limiter protects. For a
public API whose main risk is overload, serving is usually right: losing Redis
shouldn't take the API down with it. For a login endpoint, where the limiter is
what stops password guessing, refusing with 503 is the safer choice.
What it costs
With Redis on the same machine, the limiter added about 0.12 to 0.16 ms to the median request, over three runs of 3,000 requests each.
The number that carries over to production is the round trips. Redis's own command statistics showed three commands per request for a new address and four for a returning one, sent one after another. With Redis half a millisecond away on your network, expect roughly 1.5 to 2 ms per request, and keep Redis close to the gateways.
What this does not do
- It limits addresses, not users. Everyone behind one office or mobile network address shares one counter. Limits per user or per API key need the user's identity, which comes after authentication.
- It doesn't stop a flood from reaching your servers. Every refused request still arrives and costs a Redis lookup. Large-scale attacks are stopped in front of your servers, by your hosting provider or a CDN.
- IPv6 makes addresses cheap. Each address gets its own counter, and a single IPv6 network can hold an enormous number of them.
- For checking callers against long allow lists rather than counting them,
see IP allow lists with CIDR, built on the
same network-matching package as
safeNetworks.
FAQ
How do I rate limit an Express API that runs on several instances?
Keep the counter in a store every instance shares. With @imqueue/http-protect,
point every instance at the same Redis and mount protect.jsonMiddleware()
first. Three instances and one client gave exactly the configured limit, the
same as a single instance.
Why did my rate limiter block a client that sends very few requests?
HttpProtect counts a continuous stream. The counter is forgotten only after
ttl seconds with no request from that address, so a slow client that never
pauses that long keeps counting. Raise maxRequests, or lower ttl below a
normal user's pauses.
Can a client fake its IP address to avoid the rate limit?
Yes, if the limiter believes X-Forwarded-For from anyone, which is the
default. We sent 1,000 requests with a different header each time and none was
limited. Trust the header only from your own proxy, with Express's trust proxy
or the proxy-addr package.
Can someone get another user banned?
Yes, with the same default. Requests with a forged X-Forwarded-For naming a
customer's address are counted against that customer, and past banLimit the
customer is banned. A resolver that trusts only your proxy counts the attacker's
real address instead.
How do I unban an IP address?
Remove it from the Redis set:
redis-cli SREM imq:http-protect:block-list <address>. Bans never expire on
their own, and the package has no method that lifts one.
Why is my health check being rate limited?
Probably because Node reports IPv4 clients as ::ffff:a.b.c.d, which doesn't
match a.b.c.d/32. Strip the ::ffff: prefix in getClientIp, or list both
forms in safeNetworks.
What happens to the rate limiter when Redis is down?
With default options, each request waits about ten seconds and then fails.
Set enableOfflineQueue: false in redisOptions to fail at once, then catch
the error and either serve the request or answer 503.
Reference
HttpProtect ·
HttpProtectOptions ·
HttpProtectOptions.getClientIp ·
HttpProtectOptions.safeNetworks ·
HttpProtectOptions.ttl ·
HttpProtectOptions.maxRequests ·
HttpProtectOptions.banLimit ·
HttpProtectOptions.redisOptions ·
HttpProtect.verify() ·
HttpProtect.jsonMiddleware() ·
VerificationStatus ·
IP allow lists with CIDR
Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.
Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.