Because, unless you tell Express a proxy sits in front of it, it treats the proxy's address as the client's. Everyone gets the same IP, and the rate limit (the cap on attempts per person) becomes a single ceiling for the whole application. On a login limited to 30 attempts, 30 attempts summed across all users lock everyone out. The fix is to declare how many proxies sit in front with app.set('trust proxy', N). I checked the sources on October 10, 2026.
What the documentation says
A reverse proxy is the server in front of your application that forwards requests, such as nginx, a CDN or a load balancer. When it forwards, the visitor's real address travels in a header called X-Forwarded-For.
The Express documentation explains that by default (trust proxy off), the app assumes it is directly exposed to the client and derives the IP from req.socket.remoteAddress. Behind a proxy, that returns the proxy's internal address. The documentation calls this the most common problem.
With the setting on, req.ip and req.ips are computed from the socket address and X-Forwarded-For. If the value is a number, it says how many hops to trust: the first hop is the socket, and the rest are looked up in the header, from right to left. The value 0 means there is no proxy.
The express-rate-limit documentation describes the two symptoms of this mistake. One warning (ERR_ERL_UNEXPECTED_X_FORWARDED_FOR) appears when the header arrives but trust proxy is off, and the text says this configuration makes the limit apply globally instead of per user. The other (ERR_ERL_PERMISSIVE_TRUST_PROXY) appears when trust proxy is true, because then the IP is the leftmost entry of the header, which the client or the proxy can set.
What I saw in production
I found this on July 29, 2026, still live in production, in an Express API behind nginx. In development nothing showed up, because there is no proxy there.
I sent five requests with different X-Forwarded-For values to a login route:
- without the declaration: responses
409,409,429,429,429, andreq.ipalways127.0.0.1(the409was the route's normal response to the test request;429is "too many requests", the limiter blocking); - with
trust proxyset to1:409on all five.
So without the declaration, attempts from different addresses drained the same bucket, and with it none were blocked.
It is an availability failure, not a security one: 30 cheap requests turned into a denial of service on login.
Why true is worse
It is tempting to fix this with app.set('trust proxy', true). The Express documentation warns that with true, the last trusted proxy must strip or overwrite X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, otherwise the client can supply any value it wants. For the rate limit, that means changing the header on every request is enough to get unlimited attempts. It is worse than having no limit, because it looks protected.
For the same reason, I do not fix it by reading the header by hand in a key generator: the first item of X-Forwarded-For can be forged by the client. When you declare the hop count, the framework does the correct reading itself.
Side effect
The places in the code that store req.ip (the IP of the terms-of-service acceptance, logs, billing records) were saving the proxy's address, which identifies nobody. After the fix they store the real one. Before worrying about this change, check whether some code path was already reading the header by hand as a workaround. In my case, the contract acceptance already did, so nothing changed there.
What to do
- Count the hops between the client and the application: 1 for client, nginx, app; 2 if a CDN sits in front. Declare that number, never
true. - Check the result by sending requests with different
X-Forwarded-Forvalues and looking atreq.ipand the responses. - Write a test that asserts the configuration against the two opposite mistakes, zero hops and
true, and a case proving that someone forging the header stays tied to the real address. - Do not ignore the
express-rate-limitwarning: it exists to catch exactly this.
// client -> nginx -> app: one hop
app.set('trust proxy', 1);
If you want a web system with authentication and limits configured correctly from the start, see systems and SaaS.