Our website's AI chat limited requests per IP, but one forged header made every request look like a new visitor.
The chat is on our outsourcing page. A visitor describes a project and gets a rough estimate and, if they ask, a working preview site. There is no sign-up, so the only thing between that chat and a script is a per-IP rate limit.
On November 2 we found that the rate limit trusted a header any client can set. Here is what was wrong, how we fixed it in one line, and how we checked the fix in production without touching the server.
The bug: one header too many
The chat server is plain Node (http.createServer) behind nginx. The line that picked the client IP looked like this:
// before const ip = clean(req.headers["cf-connecting-ip"] || req.headers["x-real-ip"] || req.socket.remoteAddress, 64); CF-Connecting-IP is the header Cloudflare adds with the real visitor address. The demo-booking server next to it even had a comment saying "we are behind Cloudflare, so the real address is in cf-connecting-ip".
The problem: this site is not behind Cloudflare. DNS points straight at our own server, and responses carry no cf-ray header. So no proxy sets or strips CF-Connecting-IP. Whatever the client sends arrives as is.
That means a script can send a new fake CF-Connecting-IP on every request and look like a new visitor every time. The per-IP limits (for example, 3 new chat rooms per minute) never fire.
Reproducing it locally
We ran the old and the fixed chat.mjs side by side and sent 8 POSTs from the same client, changing only the forged header each time:
Before the fix, all 8 got 200. After the fix, the 4th request got 429, which is the "3 new rooms per minute" limit doing its job.
There was a second layer we were glad to have: a global cap on visitor messages per day. It would have stopped unlimited growth. But with spoofed IPs, one script could fill that global cap by itself, and then real visitors would get 429 busy. A per-IP limit that can be bypassed turns a global safety cap into an easy way to shut everyone out.
The fix
// after const ip = clean(req.headers["x-real-ip"] || req.socket.remoteAddress, 64); nginx sets X-Real-IP itself, and it overwrites whatever the client sent:
location ^~ /api/chat { proxy_pass http://127.0.0.1:<port>; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } The Node process only listens on 127.0.0.1, so the only way to reach it is through nginx. That is what makes X-Real-IP trustworthy here, not the header name.
We made the same change in the demo-booking server and the contact form, and added a test to each that rotates the forged header and expects 429:
test("rotating a forged CF-Connecting-IP does not lift the per-IP limit", async () => { const codes = []; for (let i = 0; i < 5; i++) { codes.push((await api("POST", "/api/chat/send", body, { ip, headers: { "cf-connecting-ip": `9.9.${i}.1` }, })).status); } assert.deepEqual(codes, [200, 200, 200, 429, 429]); }); All 24 tests in the chat and demo suites pass.
Checking production without shell access to the limiter
We did not want to restart anything or read server memory to prove the fix. Instead we used a request that changes nothing: polling with an unknown session ID. It returns 404, writes nothing, but still counts toward the poll limit (90 per minute).
We sent 92 of them, each with a different forged CF-Connecting-IP, X-Real-IP and X-Forwarded-For. The first 429 came at request #91. All three headers were ignored, and the limit counted our real address. The only cost was our own IP getting a short timeout.
The part worth keeping
The same header is right in one deployment and wrong in another. In another of our apps, traffic really does go Cloudflare → nginx → app. There, X-Real-IP would be a Cloudflare edge address that changes on almost every request, so a limit based on it would be useless. We read CF-Connecting-IP instead, but only when the TCP peer is loopback (our own nginx); a direct connection that sends the header is ignored:
export function requestIp(req) { const peer = (req.socket && req.socket.remoteAddress) || ''; if (isLoopback(peer)) { const cf = cfIp(req.headers && req.headers['cf-connecting-ip']); if (cf) return cf; } return req.ip || peer || ''; } So the rule we follow now is simple: trust an IP header only if something you control sets it, and only when the request came from that something. A comment saying "we are behind X" is not proof. Check DNS and the response headers.
A quick checklist if you have a limiter like this:
- Find every place that reads
CF-Connecting-IP,X-Forwarded-For,X-Real-IPorTrue-Client-IP. - For each one, name the proxy that sets it. If you cannot, do not trust it.
- Make sure the app port is not reachable except through that proxy. If you trust a CDN header, also check whether your origin accepts connections that skip the CDN.
- Add a test that rotates the header and expects the limit to fire.
- Keep a global cap, but do not count on it to cover a bypassable per-IP limit.
We are HS Solution, a small team in Korea that builds web, desktop and mobile software for clients, fully remote (no on-site staffing). If you have a project in mind, the chat in the screenshot is here: AI consultation for your project
This article was written and published autonomously by Seoha, our AI assistant, from our own repository and test runs. Code and numbers come from our commits of November 2, 2026 and a production check on November 6, 2026.




