X-Forwarded-For in nginx: log the real client IP without trusting forged headers

Behind a proxy, $remote_addr is the proxy. The realip module fixes that, but only trust the header from your own proxies.

1. The proxy adds the header

server {
    listen 80;
    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://forever-xff-app:80;
    }
}

$proxy_add_x_forwarded_for appends the address that connected to the proxy to any X-Forwarded-For header the client already sent. A visitor who sends X-Forwarded-For: 1.2.3.4 arrives at your app as 1.2.3.4, <real IP>. Only the last entry, added by your own proxy, can be trusted.

2. The app trusts only that proxy

set_real_ip_from 172.29.0.10;   # your proxy / load balancer address(es)
real_ip_header X-Forwarded-For;
real_ip_recursive on;

server {
    listen 80;
    location / { return 200 "client=$remote_addr xff=$http_x_forwarded_for\n"; }
}

set_real_ip_from lists the addresses allowed to tell nginx the client IP: your proxy or load balancer, or its subnet if it has no fixed address. Behind a CDN, list the CDN's published IP ranges. With real_ip_recursive on, nginx reads X-Forwarded-For from right to left, skips trusted addresses, and uses the first one it doesn't trust.

3. What we measured

The visitor was 172.29.0.1, the proxy 172.29.0.10. "Forged" means the visitor sent X-Forwarded-For: 1.2.3.4.

App config$remote_addr, normal$remote_addr, forged
No realip config172.29.0.10 (the proxy)172.29.0.10
set_real_ip_from 172.29.0.10 + recursive (above)172.29.0.1 ✔172.29.0.1 ✔ (forgery ignored)
set_real_ip_from 0.0.0.0/0 + recursive172.29.0.11.2.3.4 ✘ (visitor chose their IP)
set_real_ip_from 0.0.0.0/0, recursive off172.29.0.1172.29.0.1

The last row looks fine with one proxy, but trusting 0.0.0.0/0 means nginx accepts the header from any machine that connects. Someone who reaches the app directly, bypassing the proxy, can then set their own IP. Rate limits, IP allow-lists and logs that rely on $remote_addr would believe them. Always list your real proxies.

4. Check it

Return the values from a test location, as we did: return 200 "client=$remote_addr xff=$http_x_forwarded_for\n";. Then call it through the proxy with and without -H 'X-Forwarded-For: 1.2.3.4'. The client value must not change when you forge the header.

Frequently asked questions

Why does nginx log my load balancer's IP instead of the visitor's?

Because the load balancer opens the connection. Add set_real_ip_from (the balancer's address or subnet) and real_ip_header X-Forwarded-For to the nginx that sits behind it.

Can users fake X-Forwarded-For?

Yes. Any client can send the header. In our test, trusting all addresses with real_ip_recursive on let a visitor become 1.2.3.4. Trust only your own proxies.

What is the difference between X-Forwarded-For and X-Real-IP?

X-Forwarded-For is a list that each proxy appends to; X-Real-IP is a single address a proxy sets. Either works with real_ip_header, if only your proxy can set it.

Does X-Forwarded-For hide my IP from a website?

No. The server always sees the address that connected. The header only matters to servers set up to trust it.

Written with AI assistance. All four configs were run with nginx:alpine 1.31.6 (one nginx as reverse proxy, one as the app, on a Docker network) on 2026-10-01, with and without a forged X-Forwarded-For header from the client. PlainHeaders is our extension; the other methods are free.