X-Forwarded-For: Client IP Behind Proxies and the Trust Problem
X-Forwarded-For carries a chain of IP addresses appended by each proxy that handles a request. The leftmost IP is the claimed client IP — but it can be spoofed. Backend applications must only trust IPs added by proxies they control. Trusting the wrong X-Forwarded-For position enables IP spoofing for rate limiting, geo-blocking, and access control bypass.
How X-Forwarded-For works
When a request passes through proxies, each proxy appends the IP it received the request from to the X-Forwarded-For header:
Client (1.2.3.4) → Load Balancer (10.0.0.1) → App Server
Request at App Server:
X-Forwarded-For: 1.2.3.4, 10.0.0.1
^ ^
Client IP LB internal IP (added by LB)
Reading left to right: the leftmost value is the client's IP (or what the first proxy claims the client's IP is). Each hop appends its own view of the source IP to the right.
The X-Real-IP header is a simpler alternative set by some proxies (like nginx's proxy_set_header X-Real-IP $remote_addr) — it contains a single IP rather than a chain.
The spoofing problem
A client can send any X-Forwarded-For value in the request:
curl -H "X-Forwarded-For: 8.8.8.8" https://your-app.com/
If your app reads request.headers['X-Forwarded-For'].split(',')[0] and trusts that as the client IP, an attacker from IP 1.2.3.4 now appears to be 8.8.8.8. This bypasses:
- Rate limiting by IP
- Geo-blocking ("only serve US IPs")
- IP allowlists
The load balancer appends the real IP on the right:
X-Forwarded-For: 8.8.8.8, 1.2.3.4 ← LB added 1.2.3.4 (the real client IP)
The value the LB adds is trustworthy because the LB is your infrastructure. The value the client sent (8.8.8.8) is not.
Trust the rightmost IPs added by proxies you control — never the leftmost IPs set by the client
GotchaNetworking / SecurityThe correct client IP is at position -(n+1) from the right, where n is the number of trusted proxies in your infrastructure. With one load balancer, trust the second-from-right value (the one your LB added). With two proxies (CDN → LB), trust the third-from-right. The leftmost value is under client control and can contain any IP string including private addresses, IPv6, or comma-separated lists designed to confuse parsers.
Prerequisites
- HTTP headers
- Reverse proxies
- Load balancers
Key Points
- Never use the leftmost XFF value for security decisions — it's client-controlled.
- Trust only IPs appended by proxies in your own infrastructure (your LB, your CDN termination point).
- AWS ALB: use the X-Forwarded-For header's last value (the ALB appends the real client IP).
- Cloudflare: use CF-Connecting-IP header instead — Cloudflare sets this and clients cannot override it.
Nginx: extracting real client IP
# Trust the load balancer at 10.0.0.0/8
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# real_ip_recursive: strip IPs from the right that match trusted ranges
# Result: $remote_addr becomes the first untrusted (real client) IP
real_ip_recursive on strips trusted IPs from the right of the XFF chain until it hits an IP that's not in the trusted range. That IP becomes $remote_addr.
AWS ALB behavior
ALB appends the client IP to X-Forwarded-For. If the client sends X-Forwarded-For: spoofed, the header at your backend is:
X-Forwarded-For: spoofed, 1.2.3.4 ← ALB appended real client IP
To get the real client IP: take the last IP in the chain (the one ALB appended), not the first. Use --last semantics, not --first.
Your app reads request.headers['X-Forwarded-For'].split(',')[0] to get the client IP for rate limiting. An attacker sends X-Forwarded-For: 127.0.0.1. What happens?
mediumYour infrastructure: Client → AWS ALB → App. The ALB appends the real client IP to X-Forwarded-For. The attacker's real IP is 5.5.5.5.
AThe rate limiter sees 127.0.0.1 and may treat it as a trusted loopback address, bypassing the rate limit for the attacker
Correct!The attacker sends X-Forwarded-For: 127.0.0.1 in their request. The ALB appends their real IP: X-Forwarded-For: 127.0.0.1, 5.5.5.5. Your app reads split(',')[0] → '127.0.0.1'. If the rate limiter has special handling for loopback (common in frameworks that allowlist 127.0.0.1), the attacker bypasses rate limiting. Even without special handling, the rate limit bucket is keyed to '127.0.0.1' — a shared bucket all attackers using this technique write to, but it still misrepresents the real IP. Fix: read the last IP that ALB appended (second from right if you trust one ALB), or use the ALB's source IP ($remote_addr) directly.BThe ALB strips the spoofed header before forwarding
Incorrect.AWS ALB does not strip or validate X-Forwarded-For values sent by clients. It appends the real client IP and forwards the full header as-is.CThe request fails because 127.0.0.1 is not a valid external IP
Incorrect.HTTP headers are not validated for IP address format or reachability. Any string can appear in X-Forwarded-For.DNothing — X-Forwarded-For is not used for security decisions in modern apps
Incorrect.Many applications use X-Forwarded-For for rate limiting, geo-blocking, audit logging, and access control. Misreading it is a real security vulnerability.
Hint:What does split(',')[0] return when the header is 'spoofed, real_ip'?