Client IP behind proxies
Per-client rate limits, IP bans, fraud checks and access logs all need the client’s address. Behind a proxy, servers don’t see it. This page covers how the address travels and how much of it to trust.
The problem
A reverse proxy is a server to the client and a client to the backend. The backend’s TCP connection comes from the proxy, so its view of “who connected” (RemoteAddr in Go) is the proxy’s address. In the lab you see it directly: the backend logs the proxy’s port, not the client’s.
The client’s identity has to travel inside the request or connection instead.
At L7: X-Forwarded-For
The proxy writes the client’s IP into a header before forwarding. If the header already exists (the request came through another proxy first), it appends rather than overwrites, building a chain:
X-Forwarded-For: client, proxy1, proxy2Requests seen by a backend behind one proxy:
| Request | RemoteAddr (TCP) |
X-Forwarded-For |
|---|---|---|
| Straight to the backend | the client | (empty) |
| Through the proxy | the proxy | 203.0.113.7 (the client) |
Through the proxy, client sent a fake header 1.2.3.4 |
the proxy | 1.2.3.4, 203.0.113.7 |
What to trust: count from the right
Headers are text the client sent. Anyone can put anything in them. Only the TCP connection’s address is hard to fake, because replies must reach it for the connection to work.
In the chain 1.2.3.4, 203.0.113.7:
203.0.113.7, the rightmost entry, was added by your proxy from a real connection address. Trustworthy.1.2.3.4, everything to its left, was already in the request when it arrived. A genuine upstream proxy, or a lie.
The rule: count from the right, skipping only proxies you control. With one proxy, trust only the last entry. A backend that takes the first entry can be fooled into seeing a different IP on every request, which defeats per-IP rate limiting and bans and poisons logs. Nginx’s real_ip module implements this rule: set_real_ip_from lists your trusted proxies, and real_ip_recursive on walks the chain from the right past them.
The standard version: Forwarded
RFC 7239 defines a structured Forwarded header that carries the same information plus more (protocol, host, the proxy’s own identity):
Forwarded: for=203.0.113.7;proto=https, for=198.51.100.2It’s the standard, but X-Forwarded-For remains far more common in practice. The trust rule is the same.
At L4: no headers, so the PROXY protocol
An L4 load balancer forwards TCP connections without reading HTTP, so it can’t add a header. Two options:
-
Preserve the client’s source IP on the packets (some L4 load balancers do this in pass-through modes), so the backend’s
RemoteAddris already the client. -
The PROXY protocol (defined by HAProxy, widely supported): the load balancer prepends one short line to the connection, before any application data, saying “the real client is X”:
PROXY TCP4 203.0.113.7 10.0.0.5 51234 443\r\nThe backend must be configured to expect it. It’s the L4 equivalent of
X-Forwarded-For.
Interview version
“The L7 load balancer adds X-Forwarded-For (or Forwarded), at L4 the PROXY protocol. It’s client-controlled text, so the server trusts only entries added by our own proxies, counting from the right. The rate limiter keys on that.”
Sources
- MDN: X-Forwarded-For (including the security notes on choosing the right entry)
- RFC 7239: Forwarded HTTP Extension
- HAProxy: The PROXY protocol specification
- Nginx: ngx_http_realip_module