# trust proxy was lying to my rate limiter
The endpoint was a public one. Anyone could post an email address to it and get a demo session back, with no account and no authentication, which is exactly the kind of thing you put a throttle on before you ship it. So I did, and the throttle was there from the first commit.
// five requests per minute, per client @UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 5, ttl: 60000 } }) export class CampaignController { /* ... */ }
Six requests in a row on my machine, and the sixth came back 429. In production I could hold the button down and get a 200 every time, with the same header on all of them.
# request 1, and also request 30 HTTP/1.1 200 OK x-ratelimit-limit: 5 x-ratelimit-remaining: 4
The remaining count sat at 4 forever instead of walking down to 3, 2, 1. So the guard was counting, and then throwing the count away. Every request was landing in a fresh bucket.
## Every request came from a different client
A per-IP throttle needs to know the client's IP, and behind a proxy the socket's remote address is the proxy, not the client. Express handles this with the trust proxy setting: it tells Express how much of the X-Forwarded-For chain was written by infrastructure you control, so it can walk backwards past those hops and land on the real client.
I had never set it. The default is false, so req.ip held the address of whichever ingress node forwarded that particular request. Several nodes meant several addresses, and the throttle counted each of them correctly and separately, five requests at a time, against a key that changed underneath it.
## The first fix was wrong in an interesting way
There is one proxy in front of the app, so I trusted one hop and shipped it.
app.getHttpAdapter().getInstance().set('trust proxy', 1);
It got better. It did not get correct. The 429 started appearing, sometimes, and other times a burst still sailed through. Six minutes later I shipped a second one-line change, and that was the one that held.
- app.getHttpAdapter().getInstance().set('trust proxy', 1); + app.getHttpAdapter().getInstance().set('trust proxy', true);
The number 1 means trust exactly one hop. It is a promise about the shape of the network, and on a managed platform that promise is not yours to make. The ingress layer is free to route through one node today and two tomorrow, and it does not tell you.
When the depth was two, Express skipped one hop and stopped on an internal address rather than the client's. Some requests resolved to the client, some to a proxy, and the counter split across both.
true means walk the whole chain to the leftmost entry, which gives the same answer whether the platform routes through one node or four.
## Why that is safe here, and when it is not
This is the part I did not think hard enough about the first time. X-Forwarded-For is a request header, so a client can send one. If you trust the whole chain and a request can reach your app without passing through your ingress, anyone can put whatever they like at the front of that chain and mint an unlimited number of throttle buckets.
true is only correct when the platform is the sole path in and it appends to the header itself.
That held for us. If it had not, the fix would not be a hop count either, because a hop count has the same failure mode with a different number. The right answer for a directly reachable app is a list of trusted addresses, which is a claim about who your infrastructure is rather than how many of them there are:
// depth-independent and spoof-resistant app.set('trust proxy', ['loopback', '10.0.0.0/8', '172.16.0.0/12']);
## The one-line check that would have caught it
# against the deployed thing, not a test process for i in {1..6}; do curl -s -o /dev/null -w "%{http_code} " "$URL"; done # want: 200 200 200 200 200 429
It would have failed on the original deploy, failed again on the trust proxy: 1 version if I had run it enough times, and passed on the third. It did not exist, and no unit test could have stood in for it, because nothing wrong lived inside the process.
The guard was fine, the decorator was fine, the service was fine, and every test over all three passed in both broken versions. The bug lived in the shape of the network between the client and the socket, the one place my coverage could never reach.
What finds it is a check that runs against the deployed thing. Six requests, expect a 429 on the sixth. That is one line in a smoke test, and it is now the acceptance criterion I write into the pull request before I write the fix, because I deployed it and the header looked right is exactly how I convinced myself the first version worked.
The other habit is smaller and I use it more often. When a counter refuses to move, stop asking why the counter is broken and start asking what the key is. Nine times out of ten the thing is counting fine, into somewhere you are not looking.