How to get the real client IP from X-Forwarded-For behind Cloudflare, nginx and load balancers
X-Forwarded-For carries a visitor's IP, and the visitor can forge it. Which entry to trust behind Cloudflare, nginx and load balancers, with tested code.
X-Forwarded-For lists the IP addresses a request passed through, one entry per proxy. The visitor's address comes first and the last proxy's address comes last. The visitor can write anything into the header, so read it from the right. Skip the proxies you run and take the first address you do not run. Behind Cloudflare, read CF-Connecting-IP, and trust it only on connections from Cloudflare's published ranges.
This matters most when you check the address for fraud. Read the wrong entry, and you score your own proxy or an address the attacker picked.
#What does X-Forwarded-For look like?
Each proxy adds the address it received the request from to the end of the list. The rightmost entry is the most recent proxy. The leftmost should be the client, "assuming well-behaved client and proxies" (MDN).
Nothing makes the client behave. Cloudflare documents what happens when a visitor sends the header: it keeps their value and appends the address that connected to it. A visitor at 185.220.101.44 who sends a fake entry reaches your server with these headers:
X-Forwarded-For: 8.8.8.8, 185.220.101.44
CF-Connecting-IP: 185.220.101.44The first entry is whatever the visitor typed. Cloudflare wrote the last one, and CF-Connecting-IP holds only that address. That is why Cloudflare recommends reading CF-Connecting-IP: it has "only one IP address" (Cloudflare docs).
Where the visitor's address sits
| In front of your app | Read | The visitor is |
|---|---|---|
| Cloudflare | CF-Connecting-IP | The only address in it |
| nginx on the same host | X-Forwarded-For | The address nginx writes |
| AWS Application Load Balancer | X-Forwarded-For | The rightmost entry |
| Google Cloud external Application Load Balancer | X-Forwarded-For | Second from the right |
| Vercel | x-real-ip | The only address in it |
By default, the AWS load balancer appends the client's address to any header it receives (AWS docs). Google's appends two entries, the client and then the load balancer itself. Google also warns that it "does not verify any IP addresses that precede" those two (Google Cloud docs).
#Why can't you trust the leftmost address?
Any client can send the header, and Cloudflare, AWS and Google Cloud all add to it instead of replacing it. RFC 7239 defines the standard Forwarded header and calls X-Forwarded-For non-standard. It warns that such a header "cannot be relied upon to be correct": every node on the way can change it, the client included.
Real software ships this mistake. OpenClaw before version 2026.2.21 read the leftmost value from its trusted proxies. Attackers could spoof their address to sway login rate limits and IP-based access rules (CVE-2026-32029, NVD, March 19, 2026).
Express makes the same choice if you let it. Its trust proxy value true takes "the left-most entry" (Express docs). In our test with Express 5.2.1, a request carrying X-Forwarded-For: 8.8.8.8 came out with req.ip set to 8.8.8.8. With the default false, it came out as 127.0.0.1, the proxy's own address (lab run, October 23, 2026).
MDN states the rule in one line: a security use of the header "must only use IP addresses added by a trusted proxy".
#What happens if you check the wrong address?
A fraud check scores whatever address you give it. On October 23, 2026, at 08:37 UTC, we sent FFraud's free API the four addresses a server could end up with for one visit. The visitor was a Tor exit from the Tor Project's exit list of that morning.
- The visitor, a Tor exit (185.220.101.44)100
- A Cloudflare edge (162.158.88.1)40
- The forged entry (8.8.8.8)5
- A private load balancer (10.0.12.7)0
Source: FFraud public API (api.ffraud.com/public/ip), October 23, 2026
Trust the leftmost entry, and a Tor exit that scored 100 passes as Google's 8.8.8.8, which scored 5. Read the socket behind Cloudflare, and you score Cloudflare. Its edge address scored 40 as Cloudflare's WARP VPN (
#How do you set up nginx behind Cloudflare?
nginx's realip module swaps the client address for one taken from a header. It does so only for connections from addresses you list as trusted (nginx docs). List Cloudflare's ranges and read CF-Connecting-IP:
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ...one line for each range at cloudflare.com/ips-v4 and /ips-v6
real_ip_header CF-Connecting-IP;location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-For $remote_addr; # one address: the visitor
proxy_set_header X-Real-IP $remote_addr;
}Setting X-Forwarded-For to $remote_addr drops whatever the client sent. The common $proxy_add_x_forwarded_for keeps the client's list and adds to it, so your app must still read it from the right.
Cloudflare says its list of ranges "needs to be updated regularly". This script rebuilds the file from the published lists. It refuses a short list, so a failed download cannot empty your trusted ranges:
#!/bin/sh
# Rebuild nginx's list of trusted Cloudflare addresses from Cloudflare's published ranges.
set -eu
out=/etc/nginx/conf.d/cloudflare-realip.conf
tmp=$(mktemp)
{
echo "# Cloudflare ranges, fetched $(date -u +%FT%TZ)"
for r in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
case "$r" in *[!0-9a-fA-F:./]*) continue ;; esac
echo "set_real_ip_from $r;"
done
echo "real_ip_header CF-Connecting-IP;"
} > "$tmp"
# A failed download must never leave nginx with a short list.
if [ "$(grep -c set_real_ip_from "$tmp")" -lt 10 ]; then
echo "too few ranges, kept the old file" >&2; rm -f "$tmp"; exit 1
fi
chmod 644 "$tmp" && mv "$tmp" "$out"
nginx -t && nginx -s reloadWe ran it on Ubuntu 24.04 with nginx 1.24.0 on October 23, 2026. It wrote 22 ranges, 15 for IPv4 and 7 for IPv6, and nginx -t passed. Then we sent three requests through the same setup, with 127.0.0.1 standing in for Cloudflare's edge:
1) from the trusted edge, CF-Connecting-IP: 185.220.101.44
app received X-Forwarded-For: 185.220.101.44
2) the same, plus a forged X-Forwarded-For: 8.8.8.8
app received X-Forwarded-For: 185.220.101.44
3) straight to the origin from 172.23.124.226, forged CF-Connecting-IP: 8.8.8.8
app received X-Forwarded-For: 172.23.124.226The forged header sent straight to the origin was ignored. Cloudflare still advises accepting traffic only from its addresses, because otherwise "the header could be spoofed". The realip list covers that inside nginx, and a firewall rule for ports 80 and 443 adds a second lock.
#How do you get the client IP in Express?
Set trust proxy to the proxies you run, never to true. Express then reads X-Forwarded-For from right to left and stops at the first address it does not trust (Express docs). The value depends on what sits in front:
| In front of Express | trust proxy |
|---|---|
| nginx on the same host | "loopback" |
| AWS Application Load Balancer | 1, one hop |
| Google Cloud external Application Load Balancer | 2, two hops |
| Cloudflare, with no nginx | Cloudflare's ranges, as an array |
We checked each row with Express 5.2.1 on October 23, 2026, and every one returned the visitor. A direct request from outside Cloudflare's ranges returned the caller's own address. A count of hops is only safe when no shorter path reaches the app, as the Express docs warn.
This server checks each signup with FFraud's free API. It caches answers, gives up after 1.5 seconds, and listens only on 127.0.0.1, so every request must pass through nginx:
import express from "express";
const app = express();
// nginx on this machine is the only proxy we trust. req.ip becomes the
// rightmost X-Forwarded-For entry that loopback did not add.
app.set("trust proxy", "loopback");
const cache = new Map(); // ip -> { at, answer }
const TTL_MS = 10 * 60 * 1000;
async function checkIp(ip) {
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < TTL_MS) return hit.answer;
const res = await fetch(`https://api.ffraud.com/public/ip/${encodeURIComponent(ip)}`, {
signal: AbortSignal.timeout(1500),
});
if (!res.ok) throw new Error(`FFraud answered ${res.status}`);
const answer = await res.json();
if (cache.size > 50000) cache.clear(); // keep memory bounded
cache.set(ip, { at: Date.now(), answer });
return answer;
}
function decide(a) {
if (a.risk === "critical" || (a.risk === "high" && a.recent_abuse)) return "block";
if (a.risk === "high" || a.risk === "medium") return "challenge";
return "allow";
}
app.post("/signup", async (req, res) => {
let verdict = "allow";
let why = "";
try {
const a = await checkIp(req.ip);
verdict = decide(a);
why = a.reason ?? "";
} catch (err) {
why = `check skipped: ${err.message}`; // fail open, see below
}
console.log(req.ip, verdict, why.slice(0, 40)); // the reason stays in your log
if (verdict === "block") return res.status(403).json({ ip: req.ip, verdict });
// "challenge": ask for a CAPTCHA or an email code before creating the account
res.json({ ip: req.ip, verdict });
});
app.listen(3000, "127.0.0.1"); // reachable only through nginxWe ran it against the live API on October 23, 2026, sending each request the way nginx would. The fourth request carries a forged entry, and the Tor exit is still the address that gets checked:
X-Forwarded-For: 185.220.101.44 -> 403 {"ip":"185.220.101.44","verdict":"block"}
X-Forwarded-For: 162.158.88.1 -> 200 {"ip":"162.158.88.1","verdict":"challenge"}
X-Forwarded-For: 8.8.8.8 -> 200 {"ip":"8.8.8.8","verdict":"allow"}
X-Forwarded-For: 8.8.8.8, 185.220.101.44 -> 403 {"ip":"185.220.101.44","verdict":"block"}#How do you get the client IP in Django?
Django leaves this job to you. Version 1.0 shipped a middleware that copied X-Forwarded-For into REMOTE_ADDR. Version 1.1 removed it, because the mechanism "cannot be made reliable enough for general-purpose use" (Django 1.1 release notes). This helper names the proxies you run and reads the header only for requests that came through one of them:
import ipaddress
import json
import urllib.request
from django.core.cache import cache
from django.http import JsonResponse
# The proxies you run in front of Django: here, nginx on the same host.
TRUSTED = [ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128")]
def _trusted(addr):
return any(addr in net for net in TRUSTED)
def client_ip(request):
"""The rightmost address that a trusted proxy did not add itself."""
peer = ipaddress.ip_address(request.META["REMOTE_ADDR"])
if not _trusted(peer):
return str(peer) # nobody in between: the socket is the visitor
hops = [h.strip() for h in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")]
for hop in reversed([h for h in hops if h]):
try:
addr = ipaddress.ip_address(hop)
except ValueError:
return None # junk where a proxy should have written an address
if not _trusted(addr):
return str(addr)
return None
def check_ip(ip):
url = f"https://api.ffraud.com/public/ip/{ip}"
with urllib.request.urlopen(url, timeout=1.5) as r:
return json.load(r)
def decide(a):
if a["risk"] == "critical" or (a["risk"] == "high" and a.get("recent_abuse")):
return "block"
return "challenge" if a["risk"] in ("high", "medium") else "allow"
def signup(request):
ip = client_ip(request)
verdict = "allow"
if ip:
try:
answer = cache.get_or_set(f"ffraud:{ip}", lambda: check_ip(ip), 600)
verdict = decide(answer)
except (OSError, ValueError):
pass # fail open: the API was slow or down
status = 403 if verdict == "block" else 200
return JsonResponse({"ip": ip, "verdict": verdict}, status=status)We ran it with Django 6.1.1 and Django's own RequestFactory on October 23, 2026. A forged leftmost entry was skipped. A caller who reached Django directly could not choose the address, and junk in the header skipped the check:
REMOTE_ADDR=127.0.0.1 X-Forwarded-For: 185.220.101.44 -> 403 {"ip": "185.220.101.44", "verdict": "block"}
REMOTE_ADDR=127.0.0.1 X-Forwarded-For: 8.8.8.8, 185.220.101.44 -> 403 {"ip": "185.220.101.44", "verdict": "block"}
REMOTE_ADDR=127.0.0.1 X-Forwarded-For: 8.8.8.8 -> 200 {"ip": "8.8.8.8", "verdict": "allow"}
REMOTE_ADDR=198.51.100.23 X-Forwarded-For: 185.220.101.44 -> 200 {"ip": "198.51.100.23", "verdict": "allow"}
REMOTE_ADDR=127.0.0.1 X-Forwarded-For: not-an-address -> 200 {"ip": null, "verdict": "allow"}#What about Laravel?
Laravel's trusted proxy constants come from Symfony, and Laravel's docs point to Symfony's page for them. Laravel offers trustProxies(at: '*') for cloud load balancers whose addresses you do not know. Symfony calls it "critical that you prevent traffic from all non-trusted sources" before you trust every proxy.
Symfony also warns that its trusted proxies feature "does not work as expected" with nginx's realip module. So in front of Laravel, let nginx pass the header and leave realip off. We did not run Laravel for this article.
#How do you get the client IP in Next.js?
Next.js 15 removed request.ip, because "these values are provided by your hosting provider" (Next.js 15 upgrade guide). On Vercel, read x-real-ip, the header that ipAddress() from @vercel/functions reads. Vercel says it matches x-forwarded-for, which Vercel overwrites, and that it does "not forward external IPs" (Vercel docs).
export async function POST(request) {
// Vercel writes the visitor's address into x-real-ip itself. It is what
// ipAddress() from @vercel/functions returns.
const ip = request.headers.get("x-real-ip");
let verdict = "allow";
if (ip) {
try {
const res = await fetch(`https://api.ffraud.com/public/ip/${encodeURIComponent(ip)}`, {
signal: AbortSignal.timeout(1500),
});
const a = await res.json();
if (a.risk === "critical" || (a.risk === "high" && a.recent_abuse)) verdict = "block";
else if (a.risk === "high" || a.risk === "medium") verdict = "challenge";
} catch {
// fail open
}
}
return Response.json({ ip, verdict }, { status: verdict === "block" ? 403 : 200 });
}We ran this handler in Node 22 on October 23, 2026, with the header set the way Vercel sets it. It returned 403 for the Tor exit, a challenge for the Cloudflare WARP address and allow for 8.8.8.8. We did not deploy it to Vercel.
Self-hosted, next start listens on 0.0.0.0 by default (Next.js CLI docs). Bind it to 127.0.0.1 with -H, put nginx in front as above, and read the single address nginx writes into X-Real-IP.
#What should happen when the check fails?
Decide this before it happens. The code above waits 1.5 seconds and then lets the signup through. That is failing open: a slow answer never locks out real people, and your other checks still run. We tested it by making every outgoing request fail, and the signup went through with the reason in the log.
Fail closed where a miss costs money, such as a payout or a gift card order. There, hold the action for review instead of approving it.
Cache each answer. FFraud's edge keeps an anonymous IP answer for up to 24 hours, so a verdict can be up to a day old. Calls with a free API key skip that cache and get a fresh answer (
Act on the risk band rather than a single flag. FFraud's docs suggest blocking critical, and high when recent_abuse is true. They suggest a challenge for medium and high, and allowing none and low (
#How do you test your own setup?
Send forged headers from outside your network and see which address your app records:
curl -s -X POST https://example.com/signup -H "X-Forwarded-For: 8.8.8.8"
curl -s -X POST https://example.com/signup -H "CF-Connecting-IP: 8.8.8.8"A correct setup records your real address both times.
#Questions people ask
Which IP in X-Forwarded-For is the client?
The leftmost entry is meant to be the client, but the client can forge it. For security, read the list from the right, skip the proxies you run, and take the first address left. Behind Google Cloud's load balancer, that is the second entry from the right.
Can X-Forwarded-For be spoofed?
Yes. Any client can send the header, and Cloudflare, AWS and Google Cloud all append to it instead of replacing it. Only the entries written by proxies you run can be trusted.
What is the difference between X-Forwarded-For and X-Real-IP?
X-Forwarded-For lists every hop. X-Real-IP carries one address, set by a proxy such as nginx or Vercel. RFC 7239 calls X-Forwarded-For non-standard and defines the Forwarded header for the same job.
Is True-Client-IP the same as CF-Connecting-IP?
Cloudflare says there is no difference besides the name. True-Client-IP needs Cloudflare's Enterprise plan, and CF-Connecting-IP carries the same address.
Why does req.ip show 127.0.0.1 behind nginx?
Express reads the socket by default, and behind nginx the socket is nginx. Set trust proxy to "loopback" and make nginx pass the visitor's address, as in the server above.
How do I get the real IP behind Cloudflare?
Read CF-Connecting-IP, but only on connections from Cloudflare's published ranges. In nginx, list those ranges with set_real_ip_from and set real_ip_header CF-Connecting-IP.
To see what FFraud returns for an address before you wire it in, paste it into the free
Sources
RFC 7239: Forwarded HTTP Extension X-Forwarded-For header Cloudflare HTTP headers Restoring original visitor IPs Cloudflare IP ranges, IPv4 and IPv6 Module ngx_http_realip_module Module ngx_http_proxy_module ($proxy_add_x_forwarded_for) Express behind proxies HTTP headers and Application Load Balancers External Application Load Balancer overview Django 1.1 release notes Configuring trusted proxies How to configure Symfony to work behind a load balancer or a reverse proxy Upgrading to version 15 next CLI Request headers CVE-2026-32029 Tor bulk exit list IP intelligence API reference Rate limits and fair use