Nginx Real Client IP in Practice: XFF Spoofing Protection
After CDN/WAF, Nginx logs and rate limits collapse to edge IPs. Covers set_real_ip_from trusted CIDRs, real_ip_header and real_ip_recursive, logging edge plus raw XFF, $binary_remote_addr for limits, Proxy Protocol, spoof bypass, and mis-ban triage.
Scope
Restoring the real client IP is the first job after CDN, WAF, or a load balancer sits in front of Nginx. Path becomes client → edge → origin Nginx. Origin always sees the edge TCP source. Three failures land together:
- Log distortion: access.log is all CDN node IPs. Attack attribution, UV, geo all die.
- Rate-limit collateral:
limit_req/limit_conncount by edge IP. A handful of CDN nodes fill the bucket; legit users get 503'd at scale. - Ban collateral: fail2ban reads the log and bans whole CDN origin CIDRs. Site goes dark.
Any architecture with a proxy hop needs ngx_http_realip_module to restore the client IP, and must stop clients from forging X-Forwarded-For to dodge limits and bans.
Prerequisites
- Nginx 1.13.0+ (
real_ip_recursivesince 1.13.0). - Built with
--with-http_realip_module(official packages and most distros include it). - Full upstream CDN / WAF origin CIDR list. A single IP is not enough.
- CDN already forwards the real client IP:
X-Forwarded-FororX-Real-IPon origin requests. - Origin already allowlists CDN origin IPs only (see on-site *Nginx Origin Protection in Practice*).
Mechanism
X-Forwarded-For is a comma list: clientIP, proxy1IP, proxy2IP. Each hop appends the previous hop at the tail. Clients can forge the header. If origin trusts it blindly, X-Forwarded-For: 1.1.1.1 rewrites logs to an arbitrary IP and makes limit keys count 1.1.1.1 — unlimited throttle bypass.
realip uses three directives:
- set_real_ip_from: which addresses are trusted proxies. Only requests from these addresses have
X-Forwarded-Foraccepted. - real_ip_header: which header supplies the IP. Commonly
X-Forwarded-For. For Proxy Protocol,proxy_protocol. - real_ip_recursive:
onwalks right-to-left, strips trusted proxy addresses, takes the rightmost untrusted address as client IP.offtakes the rightmost value as-is.
Easy mix-up: real_ip_recursive off takes the rightmost value, which is the hop nearest origin — usually the trusted CDN node. on strips trusted CIDRs right-to-left and lands on the first address the client declared. recursive on is only safe with a tight set_real_ip_from CIDR set. Once configured, $remote_addr is rewritten to the real client IP. Logs, limits, bans follow. No other module changes.
X-Real-IP is a single value. No hop chain. If origin is on the public net, an attacker forges it directly. Safer: X-Forwarded-For plus CIDR allowlist, or Proxy Protocol — carried on TCP, not injectable at the app layer. Highest reliability.
Procedure
1. Confirm realip is compiled in
`nginx -V 2>&1 | tr ' ' '\\n' | grep -i realip
# Expected: --with-http_realip_module`
No output: rebuild Nginx or switch to distro nginx-full. Missing module makes later directives fail nginx -t.
2. Trusted proxy CIDRs and IP restore
Create /etc/nginx/conf.d/realip.conf (same path on Debian/Ubuntu). Lives in http, inherited by all servers:
`# Upstream CDN / WAF origin CIDRs (example; replace with the vendor-published real CIDRs)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 2400:cb00::/32;
# Internal load balancer / reverse proxy
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;`
set_real_ip_from must cover every possible proxy source. Miss one CIDR and that hop is treated as a client. Logs mix real IPs and node IPs. Cloud CIDR lists change; audit quarterly. Aliyun, Tencent Cloud and others publish CIDR APIs and change notices.
3. Log format: keep the originals
realip rewrites $remote_addr. It does not rewrite $realip_remote_addr (original TCP source) or $http_x_forwarded_for. For later audit, log all three:
`log_format realip '$remote_addr - $remote_user [$time_local] \"$request\" '
'$status $body_bytes_sent \"$http_referer\" '
'\"$http_user_agent\" edge=$realip_remote_addr '
'xff=\"$http_x_forwarded_for\" rt=$request_time';
access_log /var/log/nginx/access.log realip;
error_log /var/log/nginx/error.log warn;`
edge=$realip_remote_addr is the edge that actually opened TCP. On anomaly, it separates a compromised node from a forged header. xff keeps the raw header for multi-hop reconstruction.
4. Rate limits and bans on the real IP
`# Rate-limit by real client IP ($remote_addr has been rewritten by realip)
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
listen 443 ssl;
server_name helixcyber.ai;
location / {
limit_req zone=req_per_ip burst=20 nodelay;
limit_conn conn_per_ip 20;
proxy_pass http://backend;
}
# Tighten separately for sensitive endpoints
location /api/login {
limit_req zone=req_per_ip burst=5 nodelay;
proxy_pass http://backend;
}
}`
Key must be $binary_remote_addr, not $remote_addr. Fixed-width binary; memory is about one-tenth.
5. Stop XFF spoof bypass (the critical step)
The above still has a hole: if the origin IP is reachable directly (CDN skipped), the attacker sends their own X-Forwarded-For. Source is not in trusted CIDRs, so set_real_ip_from does not apply and Nginx uses the real TCP source. Safety holds. Real hardening is two steps:
- Origin accepts only CDN origin CIDRs. iptables drops non-CDN at the door. Nginx only ever sees the trusted path.
- Strip extra client-controlled hops. In multi-proxy setups the rightmost hops are trusted, the left is client-controlled.
real_ip_recursive onstrips all trusted layers and yields the real client address, not a forged middle value.
`# Origin ingress: allow only CDN origin CIDRs (example CIDRs must be replaced)
iptables -I INPUT 1 -p tcp --dport 443 -s 173.245.48.0/20 -j ACCEPT
iptables -I INPUT 1 -p tcp --dport 443 -s 103.21.244.0/22 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j DROP
netfilter-persistent save`
Without touching the firewall, map can regex-validate the header and blank illegal values so garbage never hits the log:
`map $http_x_forwarded_for $valid_xff {
default \"\";
\"~^\\s*\\d{1,3}(\\.\\d{1,3}){3}(,\\s*\\d{1,3}(\\.\\d{1,3}){3})*$\" $http_x_forwarded_for;
}`
6. Higher-reliability alternative: Proxy Protocol
If the CDN or L4 LB supports Proxy Protocol, prefer it. Real address is a plaintext TCP preamble at connect time. App layer cannot inject or rewrite:
`stream {
server {
listen 443 proxy_protocol;
proxy_pass 127.0.0.1:8443;
}
}
http {
server {
listen 8443 ssl proxy_protocol;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
}
}`
listen must carry proxy_protocol. Otherwise Nginx parses the preamble as HTTP and returns 400. CDN must enable Proxy Protocol to origin, or Nginx rejects the connection for a missing preamble.
Validation
`# 1) syntax check and graceful reload
nginx -t && nginx -s reload
# 2) Access via the CDN hostname; check whether the first log column is the real public IP
tail -f /var/log/nginx/access.log
# 3) Forged-header test: when origin is allowlist-protected, this request should be rejected
curl -I -H 'X-Forwarded-For: 203.0.113.9' https://helixcyber.ai/
# 4) Confirm the rate-limit key is already keyed on the real IP
grep -c 'limiting requests' /var/log/nginx/error.log
# 5) Check that the rewritten address matches the client IP seen on the CDN side
# Use the same client to hit a third-party IP lookup site for cross-check`
Pass: log column 1 is the client public IP; edge= is the CDN node IP; forged XFF hitting the origin IP from the public net never enters (firewall drop or 403).
FAQ
FAQ 1: real_ip configured, logs still show CDN node IPs
Triage in order:
- set_real_ip_from CIDRs incomplete. Most common. Read
edge=$realip_remote_addr, compare to the list, fill the gap. - real_ip_header name wrong, or CDN not sending. CDNs use
X-Forwarded-For,X-Real-IP, or a custom header (X-Client-IP). Emptyxff=means the header never reached origin. CDN-side config. - real_ip_recursive not what you think.
offtakes rightmost. Multi-hop needsonto strip all trusted layers. - Directive at the wrong level.
set_real_ip_fromcan live inhttp,server,location. A more specific block can override and apply to only some servers.nginx -T | grep -n real_ipfor the effective config. - Config not reloaded. Must
nginx -t && nginx -s reload. Anything that only looks like it took effect without that, discard.
FAQ 2: After realip, mass mis-bans or weird rate limits
Typical: fail2ban bans a flood of seemingly distinct IPs, or legit users hit the limiter. Two root causes:
- XFF spoof poisons the ban list: attacker rotates random XFF, fail2ban eats it. Fix: collect logs only on the trusted path (tighten origin allowlist), and in fail2ban regex extract
edge=not column 1. Forged headers no longer drive bans. You under-ban real attackers; you stop mis-banning. - Thresholds not recomputed after the architecture change: counting moved from node IP to real client IP. Per-IP concurrency and rate drop sharply. Old burst/rate is now too loose or too tight. Observe 24h in
log_only, then setburstandrate. Do not flip to deny on day one.
Also: with real_ip_recursive on, if the CDN itself is multi-tier (edge → mid → origin) and the mid CIDR is not trusted, Nginx treats the mid address as the client. Fill every tier's CIDRs, or switch to Proxy Protocol.
FAQ 3: Can we trust X-Real-IP and skip set_real_ip_from?
No. X-Real-IP is a single value. No hop chain. No way to tell it was forged. Any path around the CDN (dedicated IP, IPv6 not on CDN, stale DNS) lets an attacker spoof any IP. Logs, limits, geo-bans all lie. If you still use it, both must hold: origin accepts only CDN origin CIDRs at the network layer, and set_real_ip_from lists every trusted CIDR.
Summary
Real-IP restore is trust-boundary management. set_real_ip_from defines who is trusted. real_ip_header defines what is trusted. real_ip_recursive defines how to pick one address out of an untrusted list. Miss one and logs lie; limit and ban keys lie with them.
Order: lock origin ingress to CDN origin only, then realip restore, then log format keeping edge and raw XFF for audit, then recalibrate limit and fail2ban thresholds. High-reliability path: Proxy Protocol instead of app-layer headers. Removes the forgery surface.