Get the Real Client IP: CF-Connecting-IP, X-Forwarded-For, X-Real-IP and True-Client-IP

Updated

Short answer

Behind a proxy or CDN, REMOTE_ADDR holds the proxy’s address, and the visitor’s IP arrives in a header the proxy sets: CF-Connecting-IP on Cloudflare, True-Client-IP on Akamai, and X-Forwarded-For or X-Real-IP on most load balancers and nginx. Clients can forge these headers, so trust them only on requests that come from your own proxy, and in X-Forwarded-For use the right-most address that is not one of your proxies.

On this page

Which header carries the visitor’s IP depends on the provider, and a client that reaches your server directly can forge any of them. This guide lists the header each CDN and cloud sends, shows how to pick the right address from X-Forwarded-For, and gives trusted-proxy settings for nginx, Apache, Node.js, Python, PHP and Go. More guides are on the developer hub.

Why REMOTE_ADDR shows the proxy’s IP

REMOTE_ADDR is the address at the other end of the TCP connection. A proxy ends the visitor’s connection and opens its own, so REMOTE_ADDR becomes the proxy: 127.0.0.1 for a reverse proxy on the same host, a private address such as 10.0.1.23 for a cloud load balancer, or a public edge address for a CDN.

The visitor’s IP survives only in a header that the proxy adds, and any client can send any header. So read the header only when REMOTE_ADDR belongs to a proxy you trust, and block every path around that proxy.

StackConnecting peerClient IP once trusted proxies are set
nginx$remote_addr; after setup $realip_remote_addr$remote_addr
Apache%{c}a%a and REMOTE_ADDR
PHP$_SERVER['REMOTE_ADDR']$_SERVER['REMOTE_ADDR'] after the web server fix; Laravel $request->ip(); Symfony $request->getClientIp()
Node.js, Expressreq.socket.remoteAddressreq.ip
Djangorequest.META["REMOTE_ADDR"]Middleware below
Flask, FastAPIrequest.remote_addr, request.client.hostThe same, with ProxyFix or Uvicorn’s trusted proxies
Go net/httpr.RemoteAddr (IP:port)Middleware below

Client IP headers by CDN, load balancer and proxy

Accept each header only from the provider’s addresses, or it proves nothing.

ProviderHeader with the client IPFormatAccept it only from
CloudflareCF-Connecting-IP; on Enterprise also True-Client-IPOne IPCloudflare’s ranges, plus Authenticated Origin Pulls or Cloudflare Tunnel
AkamaiTrue-Client-IP with Send True Client IP Header on (name configurable)One IP; keep Allow Clients To Set True Client IP Header offOrigin IP ACL or Site Shield
FastlyFastly-Client-IPOne IP; clients can set it unless your VCL overwrites itapi.fastly.com/public-ip-list
AWS Application Load BalancerX-Forwarded-ForList, client last; optional ip:portSecurity group admitting only the load balancer
Amazon CloudFrontX-Forwarded-For, or CloudFront-Viewer-Address via an origin request policyList, viewer last; ip:portPrefix list com.amazonaws.global.cloudfront.origin-facing and its IPv6 twin
Google Cloud Application Load BalancerX-Forwarded-For…,<client-ip>,<load-balancer-ip>: client second from the right130.211.0.0/22 and 35.191.0.0/16 (global and classic)
Azure Front DoorX-Azure-SocketIP, X-Azure-ClientIP, X-Forwarded-ForOne IP; use X-Azure-SocketIP, since users can overwrite X-Azure-ClientIPService tag AzureFrontDoor.Backend plus an X-Azure-FDID check
Vercelx-real-ip, x-forwarded-forOne IP; Vercel overwrites client valuesNot needed
Netlifycontext.ip in Functions and Edge FunctionsOne IPNot needed

Warning

An IP allowlist proves that a request passed through the provider, not through your account: Cloudflare and Azure Front Door share their ranges among all customers, and a Cloudflare Worker can send any CF-Connecting-IP to hosts outside Cloudflare. Add Authenticated Origin Pulls or Cloudflare Tunnel on Cloudflare, and the X-Azure-FDID check on Front Door.

CF-Connecting-IP and the True-Client-IP header

CF-Connecting-IP comes with every proxied request and always holds one address. X-Forwarded-For gets the same value when the request had none; otherwise Cloudflare appends the connecting address. Cloudflare’s HTTP headers reference recommends CF-Connecting-IP for that reason, or True-Client-IP, which differs only in name, needs an Enterprise plan and matches the header Akamai sends. With Pseudo IPv4 set to Overwrite Headers, IPv6 visitors get a pseudo IPv4 address in both headers and the real one in CF-Connecting-IPv6. If the header never arrives, check that the Remove visitor IP headers Managed Transform is off.

X-Real-IP vs X-Forwarded-For vs Forwarded

X-Real-IPX-Forwarded-ForForwarded
StandardNo, an nginx conventionNo, a de facto conventionYes, RFC 7239
ValueOne addressComma-separated list, one entry per hopfor=192.0.2.60;proto=https per hop; IPv6 quoted: for="[2001:db8::17]:4711"
ForgeableYes, unless your proxy overwrites itThe left part, alwaysThe left part, always

nginx’s real IP module reads X-Real-IP by default; CloudFront strips it from incoming requests. The X-Forwarded-For reference covers the syntax of both list headers, ports, IPv6 and what each load balancer and CDN writes.

X-Forwarded-For: how to find the real client IP safely

Each proxy appends the address it received the request from, and the client can write anything before the first proxy sees the header. Only the entries on the right, added by your own proxies, can be trusted. MDN’s X-Forwarded-For reference describes two safe ways to choose, both reading from the right:

  • Trusted proxy list (recommended). Walk the list from right to left, skip every address that belongs to one of your proxies, and take the first one that does not. This is the right-most untrusted address.
  • Trusted proxy count. If exactly N proxies append to the header, the client is the Nth entry from the right. It breaks when a request can take a shorter path.

A visitor at 203.0.113.7 sends a forged header through Cloudflare and an AWS load balancer:

Text
Visitor sends               X-Forwarded-For: 198.51.100.66
Cloudflare forwards         X-Forwarded-For: 198.51.100.66,203.0.113.7
Load balancer forwards      X-Forwarded-For: 198.51.100.66,203.0.113.7, 172.70.1.10
Your app sees               REMOTE_ADDR 10.0.1.23 (the load balancer)

Trusting the VPC and Cloudflare’s ranges, the app skips 10.0.1.23 and 172.70.1.10 (a Cloudflare edge) and stops at 203.0.113.7. Code that takes the left-most entry returns the forged 198.51.100.66.

Parsing rules:

  • Join all X-Forwarded-For headers in order before splitting on commas.
  • Trim spaces and strip ports: Azure Application Gateway, and AWS load balancers with client port preservation, send 203.0.113.7:4711 or [2001:db8::7]:4711.
  • Compare parsed addresses, not strings. 2001:db8::1 and 2001:0db8:0:0:0:0:0:1 are the same address, and ::ffff:203.0.113.7 is an IPv4 client on a dual-stack socket.
  • Reject entries that are not IP addresses: Express and Uvicorn pass them through as the client address, and Google warns that earlier values may contain other characters, including spaces.
  • Rate-limit IPv6 by prefix, such as the /64, since a client can switch addresses inside it at will.

Configure nginx and Apache to restore the client IP

Fix it in the web server, and every app, log and PHP script behind it sees the visitor’s IP.

nginx: set_real_ip_from, real_ip_header and X-Forwarded-For

The ngx_http_realip_module replaces $remote_addr with the address from a header, but only for requests from addresses in set_real_ip_from. Most packaged builds include it; check with nginx -V 2>&1 | grep -o with-http_realip_module.

Behind Cloudflare, build the list from Cloudflare’s published ranges and rerun the script from cron, as the ranges can change:

Shell
#!/bin/sh
# Writes /etc/nginx/conf.d/cloudflare-realip.conf and reloads nginx.
set -eu
v4=$(curl -fsS https://www.cloudflare.com/ips-v4/)
v6=$(curl -fsS https://www.cloudflare.com/ips-v6/)
conf=/etc/nginx/conf.d/cloudflare-realip.conf
{
  echo "# Cloudflare ranges, generated $(date -u +%Y-%m-%d)"
  for net in $v4 $v6; do echo "set_real_ip_from $net;"; done
  echo "real_ip_header CF-Connecting-IP;"
} > "$conf.tmp"
mv "$conf.tmp" "$conf"
nginx -t && nginx -s reload

Behind a load balancer that sends X-Forwarded-For, trust its subnet and let nginx search the list from the right:

nginx
set_real_ip_from 10.0.0.0/16;      # your load balancer subnet
real_ip_header X-Forwarded-For;
real_ip_recursive on;

With real_ip_recursive off (the default), nginx takes the last address in the header. With on, it takes the last address that is not in set_real_ip_from: the right-most untrusted rule. If a CDN sits in front of the load balancer, add the CDN’s ranges too.

Pass X-Real-IP to your app with proxy_set_header

nginx
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

After the real IP module has run, $remote_addr is the visitor, so X-Real-IP gives the app one clean value and overwrites whatever the client sent. $proxy_add_x_forwarded_for appends $remote_addr to the incoming list; on a server that faces the internet directly, use proxy_set_header X-Forwarded-For $remote_addr; to drop client entries. In log_format, $remote_addr now logs the visitor and $realip_remote_addr the proxy.

Apache: mod_remoteip with RemoteIPHeader

Run a2enmod remoteip, then:

Apache
# Behind Cloudflare
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxyList /etc/apache2/cloudflare-ips.txt

# Behind your own load balancer in a private subnet, use instead:
# RemoteIPHeader X-Forwarded-For
# RemoteIPInternalProxy 10.0.0.0/16

Create the list file from Cloudflare’s ranges:

Shell
{ curl -fsS https://www.cloudflare.com/ips-v4/; echo; curl -fsS https://www.cloudflare.com/ips-v6/; echo; } > /etc/apache2/cloudflare-ips.txt

mod_remoteip reads the header from right to left and stops at the first untrusted address. RemoteIPTrustedProxy never accepts a private address from the header as the client, which suits public CDNs; RemoteIPInternalProxy does, which suits load balancers in your network. In LogFormat, %a logs the visitor and %{c}a the proxy. Entries with a port, such as 203.0.113.7:4711, are not accepted, so remove ports at the load balancer.

Caddy, Traefik and HAProxy

Caddy reads X-Forwarded-For from the left by default, so pair trusted_proxies with trusted_proxies_strict (Caddy 2.8+). Traefik trusts X-Forwarded-* only from forwardedHeaders.trustedIPs. For HAProxy behind Cloudflare, Cloudflare’s guide sets X-Forwarded-For from CF-Connecting-IP for requests from its ranges.

TCP load balancers and the PROXY protocol

Layer 4 load balancers, such as an AWS Network Load Balancer, add no HTTP headers: they keep the client’s source address or send it in a PROXY protocol header. nginx reads that header with listen 443 ssl proxy_protocol;, set_real_ip_from <load-balancer-subnet>; and real_ip_header proxy_protocol;, Apache 2.4.31 and later with RemoteIPProxyProtocol On.

Get the client IP in Node.js, Python, PHP and Go

If you cannot change the web server, or the app sits directly behind the load balancer, configure trust in the framework. For the local and public IP of the machine your code runs on, see the guides for JavaScript, Python and PHP.

Node.js and Express: get the client IP with req.ip

JavaScript
import express from "express";
import { isIP } from "node:net";

const app = express();

// nginx on the same host, plus a load balancer in 10.0.0.0/8
app.set("trust proxy", ["loopback", "10.0.0.0/8"]);

// req.ip is copied from the header as is: drop "::ffff:" and reject anything that is not an IP
function clientIp(req) {
  const ip = req.ip?.startsWith("::ffff:") && isIP(req.ip.slice(7)) === 4 ? req.ip.slice(7) : req.ip;
  return isIP(ip ?? "") ? ip : null;
}

app.get("/ip", (req, res) => {
  res.json({ ip: clientIp(req), chain: req.ips, peer: req.socket.remoteAddress });
});

app.listen(3000);

With addresses or subnets, Express starts at the socket address, walks X-Forwarded-For from right to left, and req.ip is the first address it does not trust. Named subnets are loopback, linklocal and uniquelocal. Because Express copies the entry as is, clientIp() drops the ::ffff: prefix and rejects ports and other text.

Warning

Trust-everything settings are safe only when nothing but your proxy can reach the app. Express trust proxy: true and Uvicorn --forwarded-allow-ips="*" both return the left-most X-Forwarded-For entry, which the client controls. Laravel trustProxies(at: '*') and Symfony’s REMOTE_ADDR value trust whoever connects.

Next.js, Vercel and Netlify

Next.js 15 removed request.ip, and Next.js 16 renamed middleware.ts to proxy.ts. Read the address from the platform:

TypeScript
// app/api/ip/route.ts on Vercel
import { ipAddress } from "@vercel/functions";

export function GET(request: Request) {
  return Response.json({ ip: ipAddress(request) ?? null });
}

ipAddress() reads x-real-ip; x-vercel-forwarded-for keeps the value when another proxy sits in front of Vercel. Self-hosted behind nginx, read request.headers.get("x-real-ip") and bind Next.js to 127.0.0.1 so only nginx can set it. On Netlify, Functions and Edge Functions get context.ip.

Cloudflare Workers: read CF-Connecting-IP

JavaScript
export default {
  async fetch(request) {
    const ip = request.headers.get("CF-Connecting-IP");
    return Response.json({ ip, country: request.cf?.country, asn: request.cf?.asn });
  },
};

Cloudflare sets the header at its edge, so a Worker can rely on it for the incoming request.

Django: REMOTE_ADDR behind a proxy

Django has no setting for this; add a middleware:

Python
# myproject/middleware.py
import ipaddress

from django.conf import settings


def parse_ip(value):
    """Parse '203.0.113.7', '203.0.113.7:4711', '2001:db8::7' or '[2001:db8::7]:4711'."""
    value = value.strip()
    if value.startswith("["):
        value = value[1:].split("]", 1)[0]
    elif value.count(":") == 1:
        value = value.split(":", 1)[0]
    try:
        ip = ipaddress.ip_address(value)
    except ValueError:
        return None
    return getattr(ip, "ipv4_mapped", None) or ip


class ClientIPMiddleware:
    """Set request.client_ip to the right-most address that is not a trusted proxy."""

    def __init__(self, get_response):
        self.get_response = get_response
        self.trusted = [ipaddress.ip_network(n) for n in settings.TRUSTED_PROXIES]

    def is_trusted(self, ip):
        return any(ip in net for net in self.trusted)

    def __call__(self, request):
        ip = parse_ip(request.META.get("REMOTE_ADDR", ""))
        if ip is not None and self.is_trusted(ip):
            for hop in reversed(request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")):
                candidate = parse_ip(hop)
                if candidate is None:
                    break
                ip = candidate
                if not self.is_trusted(ip):
                    break
        request.client_ip = str(ip) if ip is not None else None
        return self.get_response(request)

In settings.py, set TRUSTED_PROXIES = ["127.0.0.1/32", "::1/128", "10.0.0.0/8"], put "myproject.middleware.ClientIPMiddleware" first in MIDDLEWARE, and read request.client_ip.

Flask: Werkzeug ProxyFix

Python
from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
# One proxy (nginx) in front of the app sets X-Forwarded-For and X-Forwarded-Proto
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)


@app.get("/ip")
def ip():
    return {"ip": request.remote_addr}

x_for is the number of proxies that append to X-Forwarded-For. ProxyFix counts and never checks who connected, so bind the app server to 127.0.0.1 or an interface only the proxy can reach.

FastAPI and Starlette: Uvicorn forwarded-allow-ips

Shell
uvicorn main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips="127.0.0.1,10.0.0.0/8"

Uvicorn applies X-Forwarded-For only to connections from --forwarded-allow-ips (default 127.0.0.1,::1, or the FORWARDED_ALLOW_IPS environment variable) and sets request.client.host to the right-most untrusted entry; CIDR ranges work. Validate the value with ipaddress.ip_address() before use.

PHP: $_SERVER[‘REMOTE_ADDR’] behind a proxy

With nginx and PHP-FPM, fastcgi_params passes REMOTE_ADDR from $remote_addr; under Apache it follows mod_remoteip. Once the web server is set up, $_SERVER['REMOTE_ADDR'] holds the visitor’s IP. Reading $_SERVER['HTTP_X_FORWARDED_FOR'] or $_SERVER['HTTP_CF_CONNECTING_IP'] unchecked is the classic spoofing bug. If you cannot change the server:

PHP
<?php
// Your proxies. Behind Cloudflare, add Cloudflare's ranges and call client_ip('HTTP_CF_CONNECTING_IP').
const TRUSTED_PROXIES = ['127.0.0.1/32', '::1/128', '10.0.0.0/8'];

function ip_in_cidr(string $ip, string $cidr): bool
{
    [$net, $bits] = explode('/', $cidr) + [1 => null];
    $ip = inet_pton($ip);
    $net = inet_pton($net);
    if ($ip === false || $net === false || strlen($ip) !== strlen($net)) {
        return false;
    }
    $bits = $bits === null ? strlen($ip) * 8 : (int) $bits;
    $mask = str_repeat("\xff", intdiv($bits, 8));
    if ($bits % 8) {
        $mask .= chr((0xff << (8 - $bits % 8)) & 0xff);
    }
    $mask = str_pad($mask, strlen($ip), "\0");
    return ($ip & $mask) === ($net & $mask);
}

function is_trusted_proxy(string $ip): bool
{
    foreach (TRUSTED_PROXIES as $cidr) {
        if (ip_in_cidr($ip, $cidr)) {
            return true;
        }
    }
    return false;
}

function client_ip(string $header = 'HTTP_X_FORWARDED_FOR'): string
{
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    if (!is_trusted_proxy($ip)) {
        return $ip; // not from your proxy: ignore all headers
    }
    foreach (array_reverse(explode(',', $_SERVER[$header] ?? '')) as $hop) {
        $hop = trim($hop);
        if (filter_var($hop, FILTER_VALIDATE_IP) === false) {
            break;
        }
        $ip = $hop;
        if (!is_trusted_proxy($hop)) {
            break;
        }
    }
    return $ip;
}

Laravel and Symfony: trusted proxies

In Laravel 13, list your proxies in bootstrap/app.php and read $request->ip():

PHP
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: [
        '10.0.0.0/8', // your load balancer subnet
    ]);
})

In Symfony 8, set them in config/packages/framework.yaml and read $request->getClientIp():

YAML
framework:
    trusted_proxies: '127.0.0.1,10.0.0.0/8'
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port']

Both return the right-most X-Forwarded-For address that is not a trusted proxy.

Go: RemoteAddr and a trusted-proxy middleware

r.RemoteAddr holds the peer’s IP:port. ClientIP() applies the right-most untrusted rule:

Go
package main

import (
	"fmt"
	"log"
	"net"
	"net/http"
	"net/netip"
	"strings"
)

var trustedProxies = []netip.Prefix{
	netip.MustParsePrefix("127.0.0.0/8"),
	netip.MustParsePrefix("::1/128"),
	netip.MustParsePrefix("10.0.0.0/8"), // your load balancer subnet
}

func isTrusted(a netip.Addr) bool {
	for _, p := range trustedProxies {
		if p.Contains(a) {
			return true
		}
	}
	return false
}

// parseHop accepts "203.0.113.7", "203.0.113.7:4711", "2001:db8::7" and "[2001:db8::7]:4711".
func parseHop(s string) (netip.Addr, bool) {
	s = strings.TrimSpace(s)
	if ap, err := netip.ParseAddrPort(s); err == nil {
		return ap.Addr().Unmap(), true
	}
	a, err := netip.ParseAddr(s)
	return a.Unmap(), err == nil
}

// ClientIP returns the peer address if it is not a trusted proxy, otherwise the
// right-most X-Forwarded-For entry that is not a trusted proxy.
func ClientIP(r *http.Request) netip.Addr {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		host = r.RemoteAddr
	}
	ip, ok := parseHop(host)
	if !ok || !isTrusted(ip) {
		return ip
	}
	hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
	for i := len(hops) - 1; i >= 0; i-- {
		hop, ok := parseHop(hops[i])
		if !ok {
			break
		}
		ip = hop
		if !isTrusted(hop) {
			break
		}
	}
	return ip
}

func main() {
	http.HandleFunc("/ip", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, ClientIP(r))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Test that a fake client IP header is ignored

Add a temporary /ip endpoint like the ones above and send forged headers with curl, using a documentation address such as 198.51.100.66:

Shell
# 1. Through the CDN or load balancer: expect your own public IP
curl -s https://www.example.com/ip \
  -H "X-Forwarded-For: 198.51.100.66" -H "X-Real-IP: 198.51.100.66" -H "CF-Connecting-IP: 198.51.100.66"

# 2. Straight to the origin (replace 203.0.113.10 with its IP): expect a blocked connection
#    -k because origin certificates are often not publicly trusted
curl -sk --resolve www.example.com:443:203.0.113.10 https://www.example.com/ip \
  -H "CF-Connecting-IP: 198.51.100.66"

# 3. Same over IPv6
curl -6 -s https://www.example.com/ip -H "X-Forwarded-For: 2001:db8::66"

If 198.51.100.66 or 2001:db8::66 comes back, your trusted list is too wide or the origin is reachable around the proxy. Then remove the endpoint.

Look up the client IP

With iplookupapi, a trusted address becomes a country, city, network or time zone: send it to the info endpoint, with the key in the IPLOOKUPAPI_KEY environment variable. In Express, with clientIp() from above:

JavaScript
const FIELDS = "ip,location.country.alpha2,location.city.name,connection.asn,connection.organization";

async function lookupIp(ip) {
  const url = new URL("https://api.iplookupapi.com/v1/info");
  url.searchParams.set("ip", ip);
  url.searchParams.set("fields", FIELDS);
  const res = await fetch(url, {
    headers: { apikey: process.env.IPLOOKUPAPI_KEY },
    signal: AbortSignal.timeout(3000),
  });
  if (!res.ok) throw new Error(`iplookupapi returned HTTP ${res.status}`);
  return (await res.json()).data;
}

app.get("/hello", async (req, res) => {
  const ip = clientIp(req);
  const info = ip ? await lookupIp(ip).catch(() => null) : null;
  res.json({
    country: info?.location?.country?.alpha2 ?? null,
    city: info?.location?.city?.name ?? null,
    asn: info?.connection?.asn ?? null,
  });
});

In Flask, after ProxyFix:

Python
import os

import requests

FIELDS = "ip,location.country.alpha2,location.city.name,connection.asn,connection.organization"


def lookup_ip(ip):
    resp = requests.get(
        "https://api.iplookupapi.com/v1/info",
        params={"ip": ip, "fields": FIELDS},
        headers={"apikey": os.environ["IPLOOKUPAPI_KEY"]},
        timeout=3,
    )
    resp.raise_for_status()
    return resp.json()["data"]


@app.get("/hello")
def hello():
    try:
        info = lookup_ip(request.remote_addr)
    except requests.RequestException:
        info = {}
    location = info.get("location") or {}
    return {
        "country": (location.get("country") or {}).get("alpha2"),
        "city": (location.get("city") or {}).get("name"),
    }

Tip

Cache results per IP for a few hours, since every lookup counts toward your monthly quota. In local development the client IP is 127.0.0.1 or a private address, which has no location.

The SDKs for JavaScript, Python and PHP wrap the same call. To enrich logs later, send up to 1,000 addresses per request to the batch IP lookup API. Under the GDPR an IP address is usually personal data, so keep raw IPs only as long as you need them and store the derived country or network where that is enough.

Frequently asked questions

What is the difference between CF-Connecting-IP and True-Client-IP?

Only the name. Cloudflare documents that both headers carry the same visitor IP. CF-Connecting-IP is sent to the origin on every plan, while True-Client-IP is an Enterprise-only Managed Transform, useful when your origin already expects Akamai’s header name. Akamai sends True-Client-IP when Send True Client IP Header is enabled in the Origin Server behavior.

Should my app read X-Real-IP or X-Forwarded-For?

Read X-Real-IP when your own nginx sets it with proxy_set_header X-Real-IP $remote_addr after its real IP module has restored the visitor, because it then holds one clean address. Otherwise read X-Forwarded-For from the right and take the first address that is not one of your proxies. Accept either header only on connections from your proxy, since clients can send both with any value.

Can CF-Connecting-IP be spoofed?

Yes, if your origin accepts connections from anywhere, because anyone can send that header straight to your server’s IP. Allow only Cloudflare’s IP ranges at the firewall and trust the header only from them. Those ranges are shared by all Cloudflare customers, so Cloudflare recommends Authenticated Origin Pulls or Cloudflare Tunnel to prove that a request came through your own zone.

Which IP in X-Forwarded-For is the real client IP?

Read the list from right to left and skip every address that belongs to a proxy you run or pay for. The first address that is not one of your proxies is the client IP. The left-most entry is whatever the client sent, so never use it for rate limiting or access control. On Google Cloud Application Load Balancers the last entry is the load balancer itself, so the client is the second entry from the right.

Why does REMOTE_ADDR show 127.0.0.1 or a 10.x address?

Your app runs behind a reverse proxy on the same host (127.0.0.1) or a load balancer in a private network (for example 10.0.0.0/8), so the TCP connection comes from that proxy. Set the trusted-proxy option of your web server or framework, such as set_real_ip_from in nginx or trust proxy in Express, and REMOTE_ADDR or req.ip shows the visitor’s address.

How do I get the client IP address in JavaScript?

In Node.js with Express, set app.set(’trust proxy’, …) to the addresses of your proxies and read req.ip. Without a proxy, req.socket.remoteAddress is the client. Browser JavaScript has no API that returns the public IP, so let your server read the address and return it, for example from a small /ip endpoint.

Look up IP addresses in your code

iplookupapi returns the location, provider, time zone and security flags of any IP address as JSON. The free plan includes 150 requests a month.

curl "https://api.iplookupapi.com/v1/info?ip=8.8.8.8" \
  -H "apikey: YOUR_API_KEY"