Get the real client IP behind a proxy or CDN
Behind nginx, Cloudflare or a load balancer your app sees the proxy's address. How X-Forwarded-For works, why it can be forged, and setups that get it right.
Put your app behind a reverse proxy, a CDN or a load balancer and every request seems to come from the same few addresses: the proxy's. The visitor's address arrives in a header instead, and that header is easy to forge. This guide shows how the headers work, the one rule that makes them safe, and the settings for nginx, Cloudflare, AWS load balancers, Express and Django. You need the real address before you can look it up, rate-limit it or log it.
X-Forwarded-For and Forwarded
Each proxy adds the address it received the request from to X-Forwarded-For, so the header reads left to right from the client towards your server (MDN):
X-Forwarded-For: <client>, <proxy1>, <proxy2>
The standard replacement is Forwarded from RFC 7239, with one for= per hop. An IPv6 address goes in quotes and square brackets there, for example Forwarded: for=192.0.2.43, for="[2001:db8:cafe::17]". X-Forwarded-For is not part of any specification, but it is the one most setups below use.
The rule: trust only what your own proxies added
Anyone can send X-Forwarded-For: 1.2.3.4 with a request. A proxy appends to what it received, so a forged value stays at the left of the list and your own proxies' entries are at the right. MDN puts it plainly: any security-related use, such as rate limiting or access control, must only use addresses added by a trusted proxy. If clients can also reach your server directly, no part of the header can be trusted.
So read the list from the right:
- Start with the address of the connection itself (the last hop), then the header's entries from right to left.
- Skip every address that belongs to one of your own proxies or load balancers.
- The first address that is not yours is the client.
The left-most entry is the client only if nobody forged it, which you can't tell. If your proxies append several headers, join them in order with commas before reading.
nginx
nginx's realip module replaces the client address with the one from a header, but only for requests from the addresses you list. It is not built by default: nginx -V lists --with-http_realip_module when your build has it.
set_real_ip_from 10.0.1.0/28; # your load balancers only, one line per range real_ip_header X-Forwarded-For; real_ip_recursive on; # skip every trusted address, not just the last hop
With real_ip_recursive on, nginx takes the last address in the header that is not in a set_real_ip_from range: the rule above. List only your proxies there, not a whole internal range: any client inside a trusted range can set the header too. $remote_addr is then the client, and $realip_remote_addr keeps the proxy's address for your logs.
Cloudflare
Cloudflare sends the visitor's address in CF-Connecting-IP, a single address, and recommends reading it rather than X-Forwarded-For (Cloudflare's headers). True-Client-IP carries the same value but is on the Enterprise plan only. One exception: with the Pseudo IPv4 setting on "Overwrite Headers", Cloudflare puts a synthetic IPv4 address in CF-Connecting-IP for IPv6 visitors and their real address in CF-Connecting-IPv6, so read that one first if you use the setting. The header is only trustworthy on requests that really come from Cloudflare, so trust it only from Cloudflare's IP ranges, and ideally accept no other traffic at your origin.
Cloudflare publishes the ranges as plain text. This turns them into nginx lines, ready for an include. It checks both downloads before it prints anything, each against its own address family, so a failed or odd answer never gives you a partial list:
set -e
v4=$(curl -sS --fail-with-body https://www.cloudflare.com/ips-v4)
v6=$(curl -sS --fail-with-body https://www.cloudflare.com/ips-v6)
octet='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
ipv4="^($octet\.){3}$octet/([0-9]|[12][0-9]|3[0-2])\$"
ipv6='^[0-9a-f]*:[0-9a-f:]*/([0-9]|[1-9][0-9]|1[01][0-9]|12[0-8])$'
if [ -z "$v4" ] || [ -z "$v6" ] ||
printf '%s\n' "$v4" | grep -qvE "$ipv4" || printf '%s\n' "$v6" | grep -qvE "$ipv6"; then
echo "unexpected answer from cloudflare.com" >&2
exit 1
fi
printf '%s\n%s\n' "$v4" "$v6" | sed 's/.*/set_real_ip_from &;/'
IPv6 ranges are only checked for their characters and prefix length here; nginx checks the rest. So keep the old include as a backup, move the new file into its place, run nginx -t, and put the backup back if the test fails. Reload nginx only after a passing test. Then add real_ip_header CF-Connecting-IP;, as in Cloudflare's own nginx example. That example assumes Pseudo IPv4 is not set to "Overwrite Headers"; with that setting, nginx would record the synthetic IPv4 addresses of IPv6 visitors. The list changes now and then, so regenerate it on a schedule.
AWS Application Load Balancer
An ALB adds the client's address to X-Forwarded-For: it creates the header, or appends to one the client sent (AWS docs). With nothing else in front, the right-most entry is the client and everything to its left came from the client. With the optional client port preservation, entries look like 203.0.113.7:4711 or [2001:db8::7]:4711; the Python function below accepts both. The routing.http.xff_header_processing.mode attribute can also preserve or remove the header instead; append is the default.
Express
Express takes the client from X-Forwarded-For only when you tell it which proxies to trust (Express behind proxies):
app.set('trust proxy', 1) // exactly one proxy in front, such as one ALB
app.set('trust proxy', ['loopback', '10.0.1.0/28']) // or your proxies' own addresses
// then req.ip is the client
Avoid app.set('trust proxy', true): it takes the left-most entry, the one a client can forge, unless your last proxy overwrites the header. A hop count or an address list walks the header from the right, as above.
Django
Django gives the connection's address as request.META["REMOTE_ADDR"] (request docs), which is your proxy's address behind a proxy, and no setting in its request documentation reads the client from X-Forwarded-For. The header arrives as request.META["HTTP_X_FORWARDED_FOR"]. A small function that applies the rule, here with a demo:
import ipaddress
import re
# your proxies and load balancers, and nothing else: an internal client inside one
# of these ranges could forge the header too
TRUSTED = [ipaddress.ip_network(n) for n in ("10.0.1.0/28", "127.0.0.1/32", "::1/128")]
def parse(entry):
"""An address from a header entry: 203.0.113.7, 203.0.113.7:4711, [2001:db8::1]:4711."""
entry = entry.strip()
if "%" in entry:
raise ValueError("an IPv6 zone is never a client: " + entry)
# [IPv6] or [IPv6]:port, as an ALB writes it; then IPv4:port
m = re.fullmatch(r"\[([0-9A-Fa-f:.]+)\]" r"(?::(\d{1,5}))?", entry)
m = m or re.fullmatch(r"(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})", entry)
if m:
entry, port = m.group(1), m.group(2)
if port is not None and int(port) > 65535:
raise ValueError("not a port: " + port)
ip = ipaddress.ip_address(entry) # ValueError for anything else
if m and m.group(0).startswith("[") and ip.version != 6:
raise ValueError("only IPv6 goes in brackets: " + entry)
return getattr(ip, "ipv4_mapped", None) or ip # ::ffff:10.0.1.5 is 10.0.1.5
def client_ip(remote_addr, x_forwarded_for=""):
"""The first address from the right that is not one of your proxies, or None."""
chain = [e for e in x_forwarded_for.split(",") if e.strip()] + [remote_addr]
for entry in reversed(chain):
try:
ip = parse(entry)
except ValueError:
return None # not an address: stop rather than guess
if not any(ip in net for net in TRUSTED):
return str(ip)
return None # every hop is one of your proxies
# a forged left-most entry, then the client, then your load balancer
assert client_ip("10.0.1.5", "1.2.3.4, 203.0.113.7") == "203.0.113.7"
# no proxy in front: the header is ignored
assert client_ip("203.0.113.7", "1.2.3.4") == "203.0.113.7"
# an internal machine outside the proxies' range can't pass itself off as someone else
assert client_ip("10.0.1.5", "1.2.3.4, 10.9.9.9") == "10.9.9.9"
# ports, IPv6 and an IPv4-mapped proxy address
assert client_ip("::ffff:10.0.1.5", "[2001:db8::7]:4711") == "2001:db8::7"
assert client_ip("10.0.1.5", "203.0.113.7:4711, ") == "203.0.113.7"
assert client_ip("10.0.1.5", "unknown") is None
# a malformed entry stops the search instead of being trimmed into a trusted hop
assert client_ip("10.0.1.5", "1.2.3.4, [10.0.1.9X") is None
assert client_ip("10.0.1.5", "203.0.113.7:99999") is None
assert client_ip("10.0.1.5", "1.2.3.4, [10.0.1.9]") is None
assert client_ip("10.0.1.5", "1.2.3.4, ::1%eth0") is None
print("client_ip: all cases pass")
In a view: client_ip(request.META["REMOTE_ADDR"], request.META.get("HTTP_X_FORWARDED_FOR", "")). The same function works in any Python framework. Keep TRUSTED to your proxies' own addresses: trusting a whole internal range such as 10.0.0.0/8 lets any machine in it forge a client address.
Then look the address up
With the visitor's real address, your server can look it up with the host.sh API. Keep the token on the server, never in the page:
curl -H "Authorization: Bearer YOUR_TOKEN" "https://api.host.sh/v2/lookup/203.0.113.7"
See Lookup and Batch. If the lookup runs in the visitor's browser instead, no header is involved: the API sees the browser's own address, and a lookup of your own address needs no token.
FAQ
Why does my app see the same IP address for every visitor?
It runs behind a reverse proxy, CDN or load balancer, and sees the proxy's address as the connection's source. The visitor's address is in a header such as X-Forwarded-For or CF-Connecting-IP, which you should read only from your own proxies.
Can X-Forwarded-For be faked?
Yes. A client can send any X-Forwarded-For value, and proxies append to it. Only the entries your own proxies added are reliable, which is why you read the list from the right and skip your proxies' addresses.
Should I use the first or the last address in X-Forwarded-For?
Neither blindly. Read from the right and take the first address that is not one of your proxies. The first address is the client only if nobody forged the header.
Which header does Cloudflare use for the visitor's IP?
CF-Connecting-IP, which holds a single address. Cloudflare recommends it over X-Forwarded-For. Trust it only on requests from Cloudflare's published IP ranges.
This page as Markdown: /guides/client-ip-behind-proxy/index.md