X-Forwarded-For and Proxy Header Hygiene
Stop spoofed X-Forwarded-For and doubled forwarding headers from corrupting your logs, rate limits, and trust decisions at the proxy.
Netbay Cloud Team
Netbay Engineering
On this page
Forwarding headers solve a real problem: your app sits behind a proxy and needs to know the true client address. But the same headers are trivially forgeable, and a proxy that blindly appends to an incoming X-Forwarded-For leaves you trusting an attacker's version of reality. This post walks through how the header composes, why naive handling corrupts logs and rate limits, and the config patterns that keep it honest.
How the chain is supposed to work
Every hop that forwards a request is responsible for appending the address it saw. A chain looks like this: client 198.51.100.9 sends to proxy 203.0.113.5 with no header; the proxy appends 198.51.100.9, so the request arrives at the backend with X-Forwarded-For: 198.51.100.9. A second proxy appends its peer and the backend sees X-Forwarded-For: 198.51.100.9, 203.0.113.5 — left to right, outermost to innermost.
The spoofing problem
An attacker can set any header they want, including X-Forwarded-For. If your proxy naively runs the join of existing and new values, you get X-Forwarded-For: 1.2.3.4, 198.51.100.9, and tools that read the leftmost value believe the attacker chose the address. Logs lie, rate limits key on the wrong value, and allowlists pass strangers. The fix is a policy of replacement at every layer you control: only the outermost proxy accepts a client-supplied header, and every hop below overwrites it.
Nginx: a single trusted point
Use set_real_ip_from to declare which upstreams are actually proxies, and real_ip_header to say where the client IP is stored. Then Nginx replaces $remote_addr with the real client address everywhere it is used.
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}real_ip_recursive on makes Nginx walk the header to the rightmost trusted hop and treat that as the client — the same left-to-right stack described above, now handled by Nginx instead of your application. With $proxy_add_x_forwarded_for the outgoing header is rebuilt from the trusted value plus the current peer, so any junk the client injected at the outer edge is discarded by construction.
Preserving the client address with a single hop
When there is exactly one proxy, the whole dance simplifies to overwriting the header with what you actually saw.
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
}Because Nginx may have already re-derived $remote_addr from a trusted header, both assignments carry the same value, and nothing the client sent survives into the forwarded request. This is the pattern to reach for on a standard single-proxy deployment.
HAProxy side
HAProxy reconstructs the header in a way that tolerates appends rather than trusting its input: keep the incoming value out of your trust horizon, then set the header for the backend explicitly.
frontend ft_web
bind :443 ssl crt /etc/haproxy/certs.pem
option forwardfor
default_backend bk_app
backend bk_app
server app1 127.0.0.1:3000option forwardfor adds X-Forwarded-For to requests headed to the backend, using the address HAProxy observed on the socket. Combined with Nginx's or your framework's real_ip handling, the composed chain stays consistent and the attacker-injected prefix never becomes the effective client address.
Takeaway
Forwarding headers are only trustworthy if you control who wrote them. Declare your trusted proxies, replace rather than append at every hop you own, and teach the backend to read the rightmost value you produced. Do that and your logs, rate limits, and allowlists stay accurate under attack. Run the same discipline on your Netbay instances — provisioning a fresh box to test with is 60 seconds away at netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance