The Real Client IP Behind a Reverse Proxy: X-Forwarded-For, Trusted Proxies and Spoofed Headers
Behind a proxy, an application only learns the client address from a header. Reading X-Forwarded-For from the right, defining trusted proxies by address, nginx, Express and Laravel settings, the PROXY protocol, and how spoofed headers slip past rate limits.
The moment you put a load balancer, CDN or reverse proxy in front of an application, the address on the other end of the TCP connection belongs to that proxy, not to the visitor. The real client address only reaches the application inside an HTTP header. Rate limiting, login lockouts, audit logs, country rules and IP allowlists all depend on that value. Two different mistakes are common here, and both look fine at first: either everyone appears with the proxy's address and a lockout triggered by one attacker shuts out every user, or the application believes a header the client wrote itself and the attacker arrives with whatever address they like.
What each header carries
X-Forwarded-For is the de facto standard. Its value is a comma-separated list, and each proxy appends the address it received the connection from. In a chain of a client, two proxies and the application, the application sees client, proxy1; the last proxy's address is not in the header at all but in the source address of the TCP connection.
X-Real-IP carries a single address. It is a convention from the nginx world, not a standard. The Forwarded header from RFC 7239 carries the same information in a structured form (Forwarded: for=198.51.100.7;proto=https); IPv6 addresses go inside brackets and quotes (for="[2001:db8::17]"), and software that understands it is still scarce. CDNs have their own headers as well: Cloudflare sends CF-Connecting-IP, Fastly sends Fastly-Client-IP, and True-Client-IP is common with Akamai. These carry a single value written by the CDN edge, but they only mean something once you have verified that the request really came from that CDN.
Layer 4 load balancers never get to touch HTTP headers. They use the PROXY protocol instead: the balancer prepends a short header containing the client address to the TCP connection, and the backend reads it before handling the rest of the connection as usual.
Reading it correctly: right to left
The leftmost value is under the client's control. Anyone can add X-Forwarded-For: 203.0.113.66 to a request, and proxies append what they saw without removing it. That is why the header is read from the right, not from the left:
- Look at the source address of the connection. If it is not a proxy you trust, that address is the client and the header is ignored entirely.
- If it is a trusted proxy, move to the rightmost address in the header.
- If that address is also on the trusted list, step one to the left. The first untrusted address is the client.
An example: the client 198.51.100.7 adds a fake X-Forwarded-For: 203.0.113.66 to its request. The proxy 192.0.2.10 forwards it to the application as 203.0.113.66, 198.51.100.7. The application receives the connection from 192.0.2.10, which is trusted; the rightmost 198.51.100.7 is not, so that is the client. Code that takes the first value would answer 203.0.113.66, and an attacker writing a different address on every request would never hit the rate limit.
Define trusted proxies by address, not by count. "Skip one hop from the right" is only correct while the chain has exactly one proxy; the day someone puts a CDN in front, the hop count changes and nobody remembers to update the configuration. A hop count also trusts the wrong address when someone skips a link in the chain and connects straight to the inner load balancer. An address list fails more loudly when it is wrong: everyone shows up with the CDN's address, and that gets noticed right away.
Configuration: edge, middle, application
The first proxy facing the internet has no obligation to preserve the X-Forwarded-For sent by the client. If nothing sits in front of it, the safest option is to overwrite the header with the address it saw. In nginx:
proxy_set_header X-Forwarded-For $remote_addr;
Inner layers append instead ($proxy_add_x_forwarded_for). When nginx itself sits behind a proxy, the realip module resolves the client address:
set_real_ip_from 192.0.2.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Without real_ip_recursive on, nginx takes the last address in the header; with it, nginx walks from right to left and picks the first address that is not on the trusted list. From then on $remote_addr holds the real client, and log lines, limit_req and allow/deny rules work with the correct address.
Application frameworks ask for the same decision, and their defaults can be dangerous. In Express, app.set('trust proxy', true) treats the leftmost address in the header as the client, which is exactly the value that can be forged. Give it addresses instead:
app.set('trust proxy', ['loopback', '192.0.2.0/24'])
In Laravel, trustProxies(at: '*') means "whoever is calling me is always a proxy". If the application really sits behind a single proxy, that gives the right answer; but if the application can be reached directly, the caller is the client itself and whatever it wrote in the header is accepted as is. With two proxies in the chain, it returns the outer proxy's address as the client. Spell out the addresses:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['192.0.2.0/24']);
})
Read the documentation of ready-made "real IP" middleware too: some of them trust the header unconditionally, take the first value, and mention it in a single warning sentence.
Layer 4: never losing the address
A load balancer working at the TCP level (cloud network load balancers, HAProxy in mode tcp) does not terminate TLS, so it cannot add headers. The PROXY protocol has to be enabled on both sides at the same time; if only one side has it, the backend mistakes the preamble for HTTP and every request breaks. In HAProxy, add send-proxy-v2 to the server line; in nginx, use listen 443 ssl proxy_protocol; together with real_ip_header proxy_protocol;.
In Kubernetes, a LoadBalancer or NodePort Service with the default externalTrafficPolicy: Cluster replaces the source address with the node's own address (SNAT) when it forwards traffic to another node. To keep the client address, use externalTrafficPolicy: Local. The price is that traffic only goes to nodes running a pod of that service, and load is no longer spread evenly across nodes.
Pitfalls
Direct access to the origin. The trusted list may contain the CDN ranges and work flawlessly; but if your server can be reached directly, bypassing the CDN, the WAF and the rate limits are bypassed too. Allow only the CDN ranges in the firewall, or use mutual TLS between edge and origin (Authenticated Origin Pulls on Cloudflare) or a tunnel.
Stale ranges. CDNs publish their address blocks (https://www.cloudflare.com/ips-v4 and ips-v6 for Cloudflare) and add new ones from time to time. If you write the list by hand once, visitors arriving through new edges show up with the CDN's address. That is not a security hole, but rate limits and lockouts become shared by everyone behind that edge. Fetch the list with a scheduled job and raise an alert when it changes.
Trusting too broadly. "Everything from the private network is a proxy" is convenient, but now every container, every pod and every side service on that network can claim any client address it wants. In Kubernetes, trusting the whole pod network means any pod in the cluster can spoof the header. Trust only the addresses that really are proxies.
IPv6 and mapped addresses. A dual-stack listener may see an IPv4 client as ::ffff:198.51.100.7, which will not match a trust list written in plain IPv4. Some load balancers append a port to the address. Normalize before you parse. IPv6 clients usually get a whole /64 rather than a single address; tying a rate limit to one address leaves the door open for anyone who rotates their address on every request.
The same header on several lines. HTTP allows a header with the same name to appear on several lines, and the correct reading is to join them in order. Code that reads only the first line sees the line the attacker added.
Not just the address. X-Forwarded-Proto and X-Forwarded-Host follow the same trust rule. If the application builds password reset links from X-Forwarded-Host and that header is not filtered, an attacker can get a reset email generated with their own domain in the link.
Logging. Write the connection address and the raw header next to the resolved client address in your log lines. When something goes wrong, that is the only way to see which layer added what.
When not to read the header at all
If there is no proxy in front of you, do not read forwarding headers at all; every line of code that reads them is a door left open for spoofing. And an IP address is a weak identity: thousands of people share one address behind carrier and corporate NAT, while VPNs and residential proxies change addresses by the minute. Resolving the real client address correctly is essential for rate limiting and audit logs to work, but it does not replace authentication. Use an IP allowlist as a second layer, not as your only defense.
A short checklist
Know every proxy in the chain by address and tell the application to trust only those. Read the header from the right and stop at the first untrusted address. Let the first layer facing the internet overwrite the incoming value. Make the origin reachable only through your proxies, and keep CDN ranges updated automatically. On layer 4, enable the PROXY protocol on both ends; in Kubernetes, measure where the source address gets lost. Finally, send a request with a fake X-Forwarded-For to your own server and see which address appears in the log; that single request tells you faster than any document whether the configuration is right.