</>CodeWithKarani

Anyone Can Forge X-Forwarded-For: Configuring nginx real_ip Correctly

Karani GeoffreyKarani Geoffrey7 min read

You built IP rate limiting. You blocked a country you never ship to. You even wrote an allowlist for the office. Then someone walks straight through all of it, and your logs cheerfully record their requests as coming from 8.8.8.8, or 127.0.0.1, or whatever address they felt like typing that morning.

The reason is almost always the same: your application is reading X-Forwarded-For and believing it. That header is not evidence. It is a suggestion written by whoever sent the request, and on the public internet that is the attacker. Any IP-based control that trusts the raw header is not a control, it is a form with a text box labelled "please enter the IP you would like to be".

The fix is not to parse the header more cleverly. It is to decide, in nginx, exactly which hops you trust, throw away everything the client wrote, and only then let your app see a source IP. That is what ngx_http_realip_module is for, and almost nobody configures it correctly.

Never let your application read X-Forwarded-For directly. In nginx, use the realip module scoped to your load balancer's real ranges only:

set_real_ip_from 10.0.0.0/8;      # your LB / proxy subnet ONLY
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

Then rate-limit and access-control on $binary_remote_addr / $remote_addr, which nginx has now rewritten to the real client. Your app reads that verified IP, never the raw header chain. If set_real_ip_from is missing, too broad, or points at the wrong subnet, the client controls their own source IP.

How an attacker spoofs X-Forwarded-For

There is no error message here, which is exactly why it is dangerous. The system looks like it works. Here is the whole attack, in one line:

curl https://your-api.example.com/login \
  -H "X-Forwarded-For: 41.90.1.1"

That request arrives at your edge with an X-Forwarded-For header already populated. The client wrote it. When your reverse proxy is configured with the common copy-paste line:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for means "take whatever was in the incoming header and append the connecting IP". So the attacker's fake value is preserved, and the real connecting address is added after it. If your application then reads "the first IP in the list" (the standard, wrong advice), it reads the attacker's invention. If it reads "the client-most IP", same thing. The header is a list the client started, and you are trusting the start of it.

Attacker XFF: 41.90.1.1 (forged) nginx + realip set_real_ip_from = LB only discards untrusted prefix Your app reads $remote_addr = real client IP The trust boundary is set_real_ip_from Only IPs inside that range may contribute to X-Forwarded-For. Everything the attacker prepended is below the boundary and dropped.
The realip module does not "read" the header, it decides which hops are allowed to have written it.

Why this happens: the header is append-only and client-started

X-Forwarded-For was designed for a chain of trusted proxies to record the path a request took. Each proxy appends the address it received the connection from. The design assumes every hop is honest. That assumption holds inside your own infrastructure. It collapses the moment the first hop is the public internet, because the very first entry in the chain is written by the original client, and the original client is not your proxy.

So the header looks like client, proxy1, proxy2. The tempting mental model is "the leftmost entry is the real client". That is true only if every entry to the right of it is a proxy you control and trust. If a random internet user sends X-Forwarded-For: 1.2.3.4, your chain becomes 1.2.3.4, <their real IP>, and the leftmost entry is now a lie they chose.

The only entries you can trust are the ones added by hops you actually operate. The realip module exists to encode exactly that: "here are my hops, trust their contributions, discard the rest, and hand me back the first address that was not vouched for by a trusted hop".

The fix, in numbered steps

Step 1: Find the real address of your trusted hop

This is the step everyone skips, and skipping it is the whole vulnerability. You must know the exact IP or subnet that connects to nginx. If nginx sits directly behind an AWS ALB, it is the ALB's subnet. Behind Cloudflare, it is Cloudflare's published IP ranges. Behind a single HAProxy box, it is that one box.

# On the nginx host, watch who is actually connecting:
ss -tn state established '( dport = :443 or sport = :443 )'

Whatever address terminates the connection to nginx is the only thing allowed to write X-Forwarded-For. Write it down. If you cannot name it, stop, because you cannot secure a boundary you cannot describe.

Step 2: Configure realip scoped to that hop only

In the http, server, or location block:

http {
    # Repeat one line per trusted proxy/subnet. NOT 0.0.0.0/0.
    set_real_ip_from 10.0.0.0/8;        # internal LB subnet
    set_real_ip_from 172.31.0.0/16;     # e.g. AWS VPC range

    real_ip_header   X-Forwarded-For;
    real_ip_recursive on;
}

real_ip_recursive on is the line that makes this safe against a forged prefix. With it on, nginx walks the header from right to left, skipping every address that is inside a set_real_ip_from range, and stops at the first address that is not. That address becomes $remote_addr. The attacker's forged 41.90.1.1 sits to the left of their real connecting IP, it is not inside any trusted range, so nginx never reaches back past the real one. With real_ip_recursive off, nginx would take a single hop and can be fooled by a longer forged chain, so leave it on whenever more than one proxy could appear.

The critical property: set_real_ip_from must list only addresses you operate. If you write set_real_ip_from 0.0.0.0/0, you have told nginx to trust every hop on Earth, and you are back to trusting the client. That single line has shipped in more "hardened" configs than I care to count.

Step 3: Rate-limit and access-control on the verified variable

Now that nginx has rewritten $remote_addr to the real client, use it, not the header:

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /login {
        limit_req zone=api burst=20 nodelay;
        # allow/deny also operate on the realip-rewritten address
        # allow 41.90.0.0/16;  deny all;
        proxy_pass http://app;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For  $remote_addr;   # not $proxy_add_...
    }
}

Notice the last line. I deliberately forward the clean $remote_addr rather than $proxy_add_x_forwarded_for, so the application receives a single, proxy-verified value it cannot misparse. If your app framework insists on reading X-Forwarded-For, it now reads one trustworthy IP. This is the same discipline I argue for in the reverse proxy config you should actually be running.

Verification: prove the spoof no longer works

From an external machine (not inside your trusted subnet), send a forged header and read it back:

curl -s https://your-api.example.com/whoami \
  -H "X-Forwarded-For: 41.90.1.1"

Your endpoint (or an nginx return 200 $remote_addr; test location) must report your real public IP, not 41.90.1.1. Confirm it in the access log too. Set the log format to include the rewritten address:

log_format realip '$remote_addr "$http_x_forwarded_for" $request';

You want to see your genuine IP in $remote_addr while the forged value sits harmlessly in the quoted $http_x_forwarded_for field. If $remote_addr shows the forged value, your set_real_ip_from is wrong or missing, go back to Step 1.

What people get wrong

  • "Just read the first IP in X-Forwarded-For." This is the single most repeated piece of advice and it is exactly the bug. The first IP is the one the client chose. Read the framework's realip-verified value instead.
  • Trusting X-Real-IP because it "is not a list". It is still a header. A client can send X-Real-IP: 1.2.3.4 just as easily. It is only trustworthy if a trusted proxy set it and you strip any inbound copy.
  • set_real_ip_from 0.0.0.0/0. Trusting everyone is identical to trusting no one. Scope it to your hops.
  • Rate limiting in the application on the raw header. Every attacker gets their own fresh bucket by rotating the header value, so your limiter counts to ten for an infinity of imaginary IPs. See why IP rate limiting alone will not stop credential stuffing.
  • Behind Cloudflare, trusting X-Forwarded-For instead of CF-Connecting-IP and Cloudflare's real ranges. Point set_real_ip_from at Cloudflare's published IP list and use real_ip_header CF-Connecting-IP;.

When it is still broken

  1. Your app still sees the proxy's IP, not the client's. The realip directives are not being reached. Check they are in a block that applies to the request (an http-level block is safest) and reload with nginx -t && nginx -s reload.
  2. Everyone shows up as the load balancer. You forgot real_ip_header X-Forwarded-For; or the LB is not sending the header. Confirm the LB adds it.
  3. Legitimate users get blocked after adding a new proxy. You added a hop (a CDN, a WAF) without adding its range to set_real_ip_from, so nginx now stops at the new proxy's IP. Add the range.
  4. Multiple proxies, still spoofable. Confirm real_ip_recursive on. With it off and two or more forwarders, a long forged chain can push nginx to the wrong hop.

The mental shift that fixes this permanently: X-Forwarded-For is not "the client's IP". It is a claim, and the realip module is where you decide whose claims you accept. Set the boundary once, at the edge, and every rate limiter and allowlist downstream inherits a source IP it can finally trust. For sustained abuse on top of this, layer it with nginx limit_req, fail2ban and Cloudflare in the right order.

Frequently asked questions

Can a client really set their own X-Forwarded-For header?
Yes. X-Forwarded-For is an ordinary HTTP request header, so any client can send X-Forwarded-For: 1.2.3.4 with a single curl flag. Your reverse proxy then appends the real connecting IP after it. If your app reads the leftmost entry, it reads the value the attacker chose. Only trust the address that nginx produces after the realip module discards untrusted hops.
What does real_ip_recursive on actually do?
It tells nginx to walk the X-Forwarded-For chain from right to left, skipping every address that falls inside a set_real_ip_from trusted range, and to stop at the first address that is not trusted. That first untrusted address becomes the real client IP. With it off, nginx only steps back one hop, which a longer forged chain can exploit when several proxies are possible.
Should I use set_real_ip_from 0.0.0.0/0 to keep it simple?
No, that is the vulnerability. 0.0.0.0/0 tells nginx to trust every hop on the internet, which means it will happily trust the address the client forged. Scope set_real_ip_from to only the real subnets of your own load balancer, CDN, or WAF, and nothing else.
How do I get the real client IP behind Cloudflare?
Set real_ip_header CF-Connecting-IP and list Cloudflare's published IP ranges in set_real_ip_from. Cloudflare sets CF-Connecting-IP to the true client, and by trusting only Cloudflare's ranges you ensure no one else can forge it. Do not trust X-Forwarded-For directly when Cloudflare sits in front.
#nginx#X-Forwarded-For#Rate Limiting#Reverse Proxy#IP Spoofing
Keep reading

Related articles