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:

At your origin
X-Forwarded-For: 8.8.8.8, 185.220.101.44
CF-Connecting-IP: 185.220.101.44

The 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 appReadThe visitor is
CloudflareCF-Connecting-IPThe only address in it
nginx on the same hostX-Forwarded-ForThe address nginx writes
AWS Application Load BalancerX-Forwarded-ForThe rightmost entry
Google Cloud external Application Load BalancerX-Forwarded-ForSecond from the right
Vercelx-real-ipThe 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.

One visitor, four addresses, four scoresFFraud score (0 to 100) of each address your code could pick for the same visit, October 23, 2026, 08:37 UTC
  1. The visitor, a Tor exit (185.220.101.44)100
  2. A Cloudflare edge (162.158.88.1)40
  3. The forged entry (8.8.8.8)5
  4. 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 (proxy and VPN detection), and FFraud's policy for that band is a challenge, so every visitor would get one. Behind a load balancer, the socket holds a private address, and every visitor would score 0.

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

/etc/nginx/conf.d/cloudflare-realip.conf
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;
server block
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:

cloudflare-realip.sh, daily from cron
#!/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 reload

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

Test run, October 23, 2026, 08:40 UTC
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.226

The 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 Expresstrust proxy
nginx on the same host"loopback"
AWS Application Load Balancer1, one hop
Google Cloud external Application Load Balancer2, two hops
Cloudflare, with no nginxCloudflare'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:

server.mjs
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 nginx

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

Output, October 23, 2026, 08:51 UTC
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:

ffraud_check.py
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:

Output, October 23, 2026, 08:42 UTC
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).

app/api/signup/route.js
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 (rate limits). The examples keep answers for 10 minutes, and the public API has no daily or monthly cap.

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 (IP intelligence docs).

#How do you test your own setup?

Send forged headers from outside your network and see which address your app records:

curl
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. What is my IP shows the address you are testing from. If either request comes back as 8.8.8.8, your app trusts a header it should not. Repeat the second request against your origin's own address, past the CDN, and expect the same result.

#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 IP reputation check.

Sources

  1. RFC 7239: Forwarded HTTP Extension, IETF, June 1, 2014
  2. X-Forwarded-For header, MDN Web Docs (Mozilla), July 4, 2025
  3. Cloudflare HTTP headers, Cloudflare, October 9, 2026
  4. Restoring original visitor IPs, Cloudflare, April 23, 2026
  5. Cloudflare IP ranges, IPv4 and IPv6, Cloudflare, October 23, 2026
  6. Module ngx_http_realip_module, nginx
  7. Module ngx_http_proxy_module ($proxy_add_x_forwarded_for), nginx
  8. Express behind proxies, Express (OpenJS Foundation)
  9. HTTP headers and Application Load Balancers, Amazon Web Services
  10. External Application Load Balancer overview, Google Cloud
  11. Django 1.1 release notes, Django Software Foundation, July 29, 2009
  12. Configuring trusted proxies, Laravel
  13. How to configure Symfony to work behind a load balancer or a reverse proxy, Symfony
  14. Upgrading to version 15, Next.js (Vercel)
  15. next CLI, Next.js (Vercel)
  16. Request headers, Vercel, December 13, 2025
  17. CVE-2026-32029, NIST National Vulnerability Database, March 19, 2026
  18. Tor bulk exit list, The Tor Project, October 23, 2026
  19. IP intelligence API reference, FFraud
  20. Rate limits and fair use, FFraud