← News
/PLAYBOOK · 2026-09-12

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:

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

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:

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 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:

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:

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.