Get the real visitor IP behind Cloudflare (Nginx, Apache, Node.js, Python, Workers)

Behind Cloudflare, every request comes from a Cloudflare edge address and the visitor's IP is in a header. Trusting that header is safe only when the request really came from Cloudflare. Here is how to restore the real IP correctly on every common stack.

On this page The headers Cloudflare sends

Put a site behind Cloudflare's proxy (the orange cloud) and your server stops seeing visitors. Every connection comes from a Cloudflare edge address such as 162.158.x.x or 2606:4700::…. The access log is useless, per-IP rate limits throttle Cloudflare instead of abusers, fail2ban bans Cloudflare, and geolocation shows Cloudflare's data centres.

Cloudflare passes the visitor's address in a header. The fix is to tell your server to use it, but only for requests that actually come from Cloudflare. Most guides skip that second part. This one covers Nginx, Apache, Node.js, Python and Cloudflare Workers, with a script that keeps Cloudflare's address list current.

The headers Cloudflare sends

From Cloudflare's HTTP headers reference:

HeaderContains
CF-Connecting-IPThe visitor's IP address, as seen by Cloudflare. One address, set by Cloudflare
X-Forwarded-ForSame value as CF-Connecting-IP when the request arrived without one. If the visitor (or another proxy) sent X-Forwarded-For, Cloudflare appends to it
True-Client-IPSame as CF-Connecting-IP (Enterprise, optional)
CF-Connecting-IPv6The real IPv6 address when Pseudo IPv4 is set to Overwrite Headers. In that mode CF-Connecting-IP and X-Forwarded-For carry a made-up Class E IPv4 address instead
CF-IPCountryTwo-letter country code when IP geolocation is on. XX means no country data, T1 means Tor

Prefer CF-Connecting-IP. Cloudflare's own docs say to use it (or True-Client-IP) rather than X-Forwarded-For for the original visitor IP.

The part most guides leave out

CF-Connecting-IP is an ordinary request header. If your origin server answers requests that don't go through Cloudflare, anyone can send it:

bash
curl -H "CF-Connecting-IP: 1.2.3.4" http://YOUR_ORIGIN_IP/

If your config trusts the header unconditionally, that request is logged, rate-limited and geolocated as 1.2.3.4. Two things together make it safe:

  1. Trust the header only when the TCP peer is a Cloudflare address. Every example below does this.
  2. Don't let anyone else reach the origin. Firewall ports 80 and 443 to Cloudflare's ranges, or use Authenticated Origin Pulls or a Cloudflare Tunnel, which Cloudflare's header docs recommend for verifying that traffic came through your zone.

Cloudflare's address ranges are published at cloudflare.com/ips, as plain text (/ips-v4, /ips-v6) and as JSON from https://api.cloudflare.com/client/v4/ips. On 12 November 2026 the lists held 15 IPv4 and 7 IPv6 ranges. They do change occasionally, so generate your configuration from the list instead of pasting it once.

Nginx

The realip module replaces $remote_addr with the header's value, but only for connections from addresses listed in set_real_ip_from (module docs). Distribution packages include it. If you compile Nginx yourself, it needs --with-http_realip_module.

The generated file looks like this:

nginx
# /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 per Cloudflare range, IPv4 and IPv6 ...
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;

This script regenerates it, validates it with nginx -t before reloading, and puts the old file back if Nginx rejects the new one:

bash
#!/bin/sh
# Regenerate nginx's list of Cloudflare addresses. Run daily from cron.
set -eu
CONF=${CONF:-/etc/nginx/conf.d/cloudflare-realip.conf}
NGINX_RELOAD=${NGINX_RELOAD:-"nginx -t && nginx -s reload"}
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

{
  echo "# Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) from https://www.cloudflare.com/ips/"
  for url in https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6; do
    curl -fsS --max-time 15 "$url" | grep -E '^[0-9A-Fa-f:.]+/[0-9]{1,3}$' | sed 's/.*/set_real_ip_from &;/'
  done
  echo "real_ip_header CF-Connecting-IP;"
} > "$tmp"

# Refuse to install a list that is suspiciously short (network error, HTML error page...)
[ "$(grep -c '^set_real_ip_from' "$tmp")" -ge 10 ] || { echo "too few ranges, keeping old file" >&2; exit 1; }

cp "$CONF" "$CONF.bak" 2>/dev/null || true
cp "$tmp" "$CONF"
if ! sh -c "$NGINX_RELOAD"; then
  echo "nginx rejected the new file, restoring the old one" >&2
  [ -f "$CONF.bak" ] && cp "$CONF.bak" "$CONF"
  exit 1
fi

Run it once by hand, then from cron, for example 17 4 * * * /usr/local/sbin/cloudflare-realip.sh. The conf.d directory is included inside the http {} block by the stock nginx.conf on Debian, Ubuntu and the official nginx packages. Check yours with nginx -T | grep conf.d.

A few details:

  • Don't add real_ip_recursive on for this setup. With CF-Connecting-IP there is only one address in the header, so there's nothing to recurse over. It matters when you use X-Forwarded-For with several trusted hops.
  • Local proxies in front of Nginx? Add their addresses with extra set_real_ip_from lines.
  • Keep the original peer in the log for debugging. $realip_remote_addr holds the Cloudflare address that was replaced: log_format cf '$remote_addr via $realip_remote_addr "$request" $status';
  • Lock the origin down with the same list in your firewall, or with an allow/deny block generated the same way.

Once this is in place, everything downstream of Nginx sees the visitor: $remote_addr, $binary_remote_addr in rate limits, access logs, allow/deny and the X-Forwarded-For value Nginx passes to your app with $proxy_add_x_forwarded_for.

Apache

mod_remoteip does the same job:

apache
# a2enmod remoteip
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPTrustedProxy 103.21.244.0/22
# ... one line per Cloudflare range (generate it like the Nginx file) ...

Then use %a instead of %h in your LogFormat, because %h keeps logging the peer.

Node.js and Express

With Nginx in front doing the above, your app just trusts Nginx: app.set('trust proxy', 'loopback'). See the Express guide.

If Node faces Cloudflare directly, load the ranges at startup and use the header only when the peer is in them:

js
import { BlockList, isIP } from 'node:net';

export async function loadCloudflareRanges() {
  const res = await fetch('https://api.cloudflare.com/client/v4/ips', { signal: AbortSignal.timeout(10_000) });
  if (!res.ok) throw new Error(`Cloudflare IP list: HTTP ${res.status}`);
  const { result } = await res.json();
  const ranges = [...result.ipv4_cidrs, ...result.ipv6_cidrs];
  if (ranges.length < 10) throw new Error('Cloudflare range list looks incomplete');
  return ranges;
}

export function cloudflareBlockList(ranges) {
  const list = new BlockList();
  for (const cidr of ranges) {
    const [net, bits] = cidr.split('/');
    list.addSubnet(net, Number(bits), isIP(net) === 6 ? 'ipv6' : 'ipv4');
  }
  return list;
}

// Use Cloudflare's headers only when the TCP peer really is Cloudflare.
export function realClientIp(req, cloudflare) {
  let peer = req.socket.remoteAddress ?? '';
  if (peer.startsWith('::ffff:') && isIP(peer.slice(7)) === 4) peer = peer.slice(7);
  if (isIP(peer) && cloudflare.check(peer, isIP(peer) === 6 ? 'ipv6' : 'ipv4')) {
    // With Pseudo IPv4 set to "Overwrite Headers", the real IPv6 address is in CF-Connecting-IPv6.
    for (const name of ['cf-connecting-ipv6', 'cf-connecting-ip']) {
      const value = req.headers[name];
      if (typeof value === 'string' && isIP(value.trim())) return value.trim();
    }
  }
  return peer;
}
js
import express from 'express';
import { loadCloudflareRanges, cloudflareBlockList, realClientIp } from './cloudflare.js';

const ranges = await loadCloudflareRanges();
const cloudflare = cloudflareBlockList(ranges);

const app = express();
app.use((req, res, next) => {
  req.visitorIp = realClientIp(req, cloudflare);
  next();
});

There's a shortcut for Express: app.set('trust proxy', ['loopback', ...ranges]). Express then walks X-Forwarded-For from the right, skips Cloudflare's hop and returns the address Cloudflare saw. That's the same value in the normal case, and req.ip works everywhere, including in middleware such as express-rate-limit.

Refresh the list on a timer (daily is plenty) or on each deploy. If the fetch fails at startup, fail loudly rather than run with an empty list, which would make every visitor look like Cloudflare.

Python (Django, Flask, FastAPI)

The same logic with the standard library. It takes Django's request.META or a WSGI environ:

python
import ipaddress
import json
import urllib.request


def load_cloudflare_networks():
    with urllib.request.urlopen("https://api.cloudflare.com/client/v4/ips", timeout=10) as resp:
        result = json.load(resp)["result"]
    nets = [ipaddress.ip_network(c) for c in result["ipv4_cidrs"] + result["ipv6_cidrs"]]
    if len(nets) < 10:
        raise RuntimeError("Cloudflare range list looks incomplete")
    return nets


CLOUDFLARE = load_cloudflare_networks()  # at startup; refresh daily in long-running apps


def _ip(value):
    try:
        ip = ipaddress.ip_address((value or "").strip())
    except ValueError:
        return None
    if ip.version == 6 and ip.ipv4_mapped:
        ip = ip.ipv4_mapped
    return ip


def client_ip(meta):
    """meta: Django's request.META (or a WSGI environ)."""
    peer = _ip(meta.get("REMOTE_ADDR"))
    if peer is not None and any(peer in net for net in CLOUDFLARE):
        for header in ("HTTP_CF_CONNECTING_IPV6", "HTTP_CF_CONNECTING_IP"):
            ip = _ip(meta.get(header))
            if ip is not None:
                return ip
    return peer

We use the JSON endpoint on purpose. When we tested, https://www.cloudflare.com/ips-v4 returned 403 to Python's default urllib user agent, while the API endpoint answered normally. If you prefer the text files, send your own User-Agent.

When Python runs behind Nginx (Gunicorn, Uvicorn), let Nginx restore the address as above and configure the app server to trust Nginx only. See the Django, Flask, Laravel and Rails guide.

Cloudflare Workers

Inside a Worker you are Cloudflare, so there is no proxy to distrust. The visitor's address is in CF-Connecting-IP, and the request.cf object already carries location and network fields:

js
export default {
  async fetch(request) {
    // Set by Cloudflare's edge for every request that reaches a Worker.
    const ip = request.headers.get('CF-Connecting-IP');

    // request.cf is missing in some previews, and most fields can be null.
    const cf = request.cf ?? {};
    const visitor = {
      ip,
      country: cf.country ?? null,        // "GR"
      region: cf.region ?? null,
      city: cf.city ?? null,
      timezone: cf.timezone ?? null,      // "Europe/Athens"
      asn: cf.asn ?? null,                // a number
      network: cf.asOrganization ?? null,
    };
    return Response.json(visitor);
  },
};

Cloudflare documents country, city, region, postalCode, latitude and longitude as nullable, and latitude/longitude as strings. Handle null everywhere, and note that request.cf isn't available in the dashboard's preview editor.

Do you need an IP API inside a Worker? For the visitor's country, usually not: request.cf.country is already there. An API lookup helps when you need the same answer on servers that aren't behind Cloudflare (a back-office job geolocating stored IPs, say), when you look up an address other than the visitor's, or for the bot signals on paid plans, such as verified AI crawlers. In those cases call it from the Worker with the key in a secret, and treat "No Data" as unknown. The Cloudflare Workers integration guide has the full setup.

CF-IPCountry vs your own geolocation

If you turn on IP geolocation in Cloudflare, the origin gets CF-IPCountry on every request. It's convenient, with two caveats:

  • It's a header, so the same rule applies: only believe it when the request came from Cloudflare.
  • XX (unknown) and T1 (Tor) aren't countries. Handle them before you map codes to currencies or legal regions.

For anything beyond the country, such as city, time zone, ISP or ASN for stored addresses, look the IP up: GET https://ipapi.gr/api/lookup/{ip}?key=…. See get a visitor's country from their IP for examples in four languages.

Mistakes we see in the top search results

  • A pasted, years-old range list. Many tutorials embed the list as it was when they were written. The list isn't fixed, so an old copy can be missing ranges. A visitor arriving through a range missing from your list shows up as Cloudflare again, and their rate limits and bans hit everyone on that edge. Generate the list.
  • Trusting CF-Connecting-IP in the application and in Nginx. Once Nginx has restored the visitor address, the app should trust Nginx (loopback), not Cloudflare's ranges, because the app's TCP peer is Nginx. Trusting Cloudflare's ranges there does no harm, but it hides the real question of who can reach the app.
  • fail2ban or a WAF reading the wrong log field. With realip, $remote_addr in the access log is the visitor, which is what fail2ban needs. Just remember that banning with the local firewall does nothing for traffic that arrives through Cloudflare: the firewall sees Cloudflare's address. Ban at the Cloudflare level (through its API or IP Access rules) or return 403 in Nginx based on $remote_addr.
  • Forgetting IPv6. If only the IPv4 ranges are listed, every visitor reaching Cloudflare over IPv6 who is forwarded to your origin over IPv6 shows up as Cloudflare. The script above includes both lists.

Check your setup

bash
# Through Cloudflare: your app should see your real IP
curl https://example.com/debug-ip

# Spoof attempt through Cloudflare: Cloudflare overwrites CF-Connecting-IP, so still your IP
curl -H "CF-Connecting-IP: 1.2.3.4" https://example.com/debug-ip

# Direct to the origin: should be refused by the firewall. If it answers, it must NOT say 1.2.3.4
curl -H "CF-Connecting-IP: 1.2.3.4" -H "Host: example.com" http://ORIGIN_IP/debug-ip

FAQ

Why does my server log 172.x or 10.x after enabling realip?

Something sits between Cloudflare and Nginx (a load balancer or a Docker network), and that hop isn't in set_real_ip_from. Add its address, or move the realip config to the first server that Cloudflare connects to.

Do I still need this if I use Cloudflare Tunnel?

With a tunnel, connections reach your service from the local cloudflared process, so the peer is 127.0.0.1 or the connector's container address. Trust that address, read CF-Connecting-IP, and you don't need the public ranges at all.

Is it safe to trust X-Forwarded-For from Cloudflare?

Cloudflare appends, so the rightmost entry it added is reliable and anything to the left may come from the visitor. CF-Connecting-IP avoids the question. If you do use X-Forwarded-For, walk it from the right as described in X-Forwarded-For spoofing.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.