Why your log rarely holds the visitor's address
A web server knows exactly one address for certain: that of the peer it opened the connection with. Put a reverse proxy in front, and that is the proxy's address. MDN puts it plainly: if a client connection passes through any forward or reverse proxies, the server only sees the final proxy's address, which is often of little use (MDN). It gets especially obvious when the final hop is a load balancer belonging to the same deployment – then every log line carries the same internal address, day after day, and the report still looks well populated.
That is exactly why X-Forwarded-For exists. The header carries a comma-separated list in which every proxy appends the address of its peer. The rightmost address therefore belongs to the most recent proxy, the leftmost to the originating client – but only under a condition MDN spells out explicitly: the ordering holds assuming well-behaved clients and proxies (MDN). A visitor who sends the header along is not well-behaved. Doing so requires nothing beyond setting a header; a single curl command on the command line is enough.
- Access logs: every line carries the address of the last hop. Reports on origin, frequency and patterns then describe your own proxy rather than your visitors.
- Blocks and rate limiting: a rule that works on your own load balancer's address hits either everyone or no one. A rule that works on a freely set value hits an uninvolved third party.
- Origin and network class: country, region and network operator are derived from the address – the Geo API shows how that works in detail. If the address is wrong, the origin is wrong, without any error becoming visible.
- Bot detection: methods that count requests per address either judge a single collective address or an arbitrary number of invented ones once the attribution slips.
How the chain forms and who appends which value
The sequence is easy to walk through with three addresses from the documentation ranges. A visitor sends a request and sets X-Forwarded-For: 203.0.113.7 themselves. The request first reaches a foreign proxy – a corporate network, say, or an anonymisation service. That proxy appends the address the visitor arrived from, here 198.51.100.4. The header now reads 203.0.113.7, 198.51.100.4. Next the request reaches your own load balancer. It, too, appends what it sees: the foreign proxy's address, here 192.0.2.55. The application receives 203.0.113.7, 198.51.100.4, 192.0.2.55, and the connection peer is the load balancer.
Three entries, three very different qualities. The first comes from the visitor and is pure invention. The second comes from the foreign proxy; its content is probably correct, but it originates from a source nobody in your organisation controls. The third comes from your own load balancer and is the only entry with evidence behind it: it was written by a component that belongs to your deployment. MDN draws an uncomfortable conclusion from this: the first trustworthy X-Forwarded-For address may belong to an untrusted intermediate proxy rather than the actual client, but it is the only address suitable for identifying a client for security purposes (MDN). Anyone hoping to pin down the exact visitor gets no promise here – anyone wanting a defensible basis for decisions does.
The question “who was that?” and the question “whom may I block?” point at different entries in the same list. For a reach report without consequences the left end will do; for anything with consequences, only your own part of the chain. MDN draws the line like this: leftmost, untrusted values must only be used where there is no negative impact from using spoofed values (MDN). How strongly altered addresses affect reporting is covered in the article on Chrome IP Protection and geodata.
Setting the trust boundary
The trust boundary is not a setting but a statement about your own deployment: which components between the internet and the application belong to me, and do they write the header reliably? MDN names two common methods for finding the first defensible entry. In the first, the count of reverse proxies between the internet and the server is configured, and the list is searched from the rightmost by that count minus one (MDN). In the second, the addresses or address ranges of the trusted reverse proxies are configured; the list is searched from the rightmost, all configured addresses are skipped, and the first non-matching address is the target (MDN). The second method survives a rebuild of the chain better, because it checks membership rather than counting positions.
| Method | Which address it yields | What it is good for | Where it breaks |
|---|---|---|---|
| Connection peer address | the last hop | deployments without any proxy | behind a load balancer or CDN, one single unchanging value |
| Leftmost entry | whatever the first node reported | coarse reach reporting without consequences | freely set by the visitor |
| Rightmost entry | whatever the last proxy appended | exactly one proxy layer of your own | with two layers of your own it yields an internal address |
| Count of your proxies | position from the right, count minus one | a fixed, known chain | one extra layer shifts the position silently |
| List of your proxies | first address from the right that is not configured | a varying number of layers | stale address ranges after a migration |
| The CDN's own field | the value the provider sets itself | when that provider overwrites the field reliably | without that provider in front, the field is freely set |
One precondition outranks every method and is routinely overlooked: the server must not be reachable directly from the internet. MDN is explicit here – if the server can be directly connected to from the internet, even if it is also behind a trusted reverse proxy, no part of the X-Forwarded-For list can be considered trustworthy or safe for security-related uses (MDN). An open port on the origin server therefore voids the entire evaluation, however carefully it was written. How to keep an eye on the origin behind a delivery network at all is covered in the article on origin monitoring behind a CDN.
What goes wrong when the boundary is missing
The consequences are not edge cases; they are written out in the documentation. Using untrustworthy values can result in rate-limiter avoidance, access-control bypass, memory exhaustion, or other negative security or availability consequences (MDN). All four share one cause: a decision rests on a value the other side controls. MITRE's weakness classification lists this exact pattern as a class of its own – a product has two different sources of the same data or information, but uses the source that has less support for verification, is less trusted, or is less resistant to attack (MITRE).
How often the pattern surfaces can be counted. A full-text search for X-Forwarded-For in the NVD vulnerability database returns 195 entries (NVD, retrieved 7 September 2026). That is not a statement about individual products but about a class of defect that keeps recurring. It becomes particularly awkward in rate limiting: count per address using a freely settable value, and you see as many distinct visitors as the other side cares to invent – the limit then holds arithmetically but not in practice. What a rate limit is meant to achieve, and how to read its state, is covered in the article on rate limits and status code 429.
A trust boundary is a setting that can be invalidated by any infrastructure change: an extra layer in front, a move to a different data centre, a new address range. It therefore belongs in a recurring check. The security scan records a page's delivered headers on a regular schedule and makes deviations visible instead of leaving them until the next incident.
Forwarded: the standardised route and its pitfalls
A standardised version of the idea has existed since June 2014. RFC 7239 defines the Forwarded field, which can carry the address, the interface that was addressed, the protocol name and the original host in a single field. MDN notes that this standardised version is used much less frequently than X-Forwarded-For (MDN). The standard changes nothing about the underlying question, and says so itself: the Forwarded header field cannot be relied upon to be correct, as it may be modified, whether mistakenly or for malicious reasons, by every node on the way to the server, including the client making the request (RFC 7239).
A request may contain several X-Forwarded-For headers. The addresses in them must be treated as a single list, starting with the first address of the first header and continuing to the last address of the last header (MDN). Read only one of them and you read past the chain.
RFC 7239 records that IPv6 addresses need neither quotes nor square brackets in X-Forwarded-For, while they carry both in Forwarded (RFC 7239). A parser that expects square brackets discards the same address depending on which field it came from.
Instead of an address, RFC 7239 permits an obfuscated identifier with a leading underscore, for example for=_hidden (RFC 7239). An evaluation has to treat such entries as what they are: not a measurement, not a zero, but a deliberately withheld value.
When translating X-Forwarded-For into Forwarded, each element is prefixed with for=; X-Forwarded-For: 192.0.2.43, 2001:db8:cafe::17 becomes Forwarded: for=192.0.2.43, for="[2001:db8:cafe::17]" (RFC 7239). The notation changes, the trust problem stays.
Two further notes from the standard concern operations. First, the field should not be copied into response messages by origin servers or intermediaries, as it can reveal the whole proxy chain to the client (RFC 7239). Second, hosting environments must take special care not to allow the TRACE request where Forwarded is used, because the field would then appear in the body of the response (RFC 7239). Both take minutes to check – and both stay unnoticed indefinitely without a check.
The setting in the web server
In nginx, the ngx_http_realip_module handles this job. The set_real_ip_from directive defines trusted addresses that are known to send correct replacement addresses (nginx). real_ip_header decides which field is read; the default is X-Real-IP, not X-Forwarded-For (nginx). And real_ip_recursive defaults to off (nginx): with recursive search disabled, the original address is replaced by the last address in the field, with recursive search enabled by the last non-trusted address (nginx). With two layers of your own, that difference decides whether the internal or the external address ends up in the log.
- The two directives belong together:
set_real_ip_fromwithout a matchingreal_ip_headerreads a different field than you think. Set and verify both as a pair. - Choose recursion deliberately: with exactly one layer of your own, the default suffices. With two or more layers you need
real_ip_recursive onplus the address ranges of every layer. - The original address stays readable: nginx keeps it in
$realip_remote_addr(nginx). Log both values and you can later show which hop supplied which value. - Apache carries the same warning: it is critical to only enable this behaviour from intermediate hosts which are trusted by this server, since it is trivial for the remote user agent to impersonate another one (Apache HTTP Server). The underlying client address of the connection remains available there through
%{c}a.
Evaluation in code: from right to left
If the web server already replaces the address, the application has nothing left to do. If the header arrives untouched, the rule is: start from the right, skip your own configured addresses, take the first foreign entry. A widely cited secondary source on the topic adds a note that is regularly missing in practice: when choosing the rightmost XFF IP, make sure to use the last instance of that header (adam-p.ca, 2022). And further: any header not specifically set by your reverse proxy cannot be trusted (adam-p.ca, 2022). That applies to the convenient fields with names like X-Real-IP as well: they are worth exactly as much as the component that wrote them.
from ipaddress import ip_address, ip_network
OUR_PROXIES = [ip_network(n) for n in ("10.0.0.0/8", "192.0.2.0/24")]
def is_ours(address):
return any(address in net for net in OUR_PROXIES)
def visitor_ip(header_values, peer):
"""First foreign address from the right. None means: not determined."""
if not is_ours(ip_address(peer)):
return None # directly reachable: no value is reliable
chain = []
for value in header_values: # every header, in order of arrival
for part in value.split(","):
try:
chain.append(ip_address(part.strip()))
except ValueError:
chain.append(None) # not a valid entry
for address in reversed(chain):
if address is None:
return None # the chain is unreadable from here on
if not is_ours(address):
return address
return None
# Call it with ALL X-Forwarded-For headers of the request
print(visitor_ip(["203.0.113.7, 198.51.100.4", "192.0.2.55"], "192.0.2.55"))
Three properties make this implementation defensible. First, it returns None instead of inventing an address when nothing defensible is available – an undetermined value is not a zero and does not belong in a report as a number. Second, it stops as soon as an invalid entry appears from the right, because from that point the chain is no longer readable. Third, it checks first whether the peer is one of your own proxies at all; if it is not, the request arrived directly and the header carries no meaning. How the same reasoning applies to data centre traffic and automated access is covered in the article on filtering bot traffic.
What this means for reach, origin and bot detection
In reach measurement a wrong address behaves differently than in access control: nothing breaks, everything merely looks plausible. Country and region are derived from the address; if that address is your own load balancer, all traffic piles up at its location. If it is a freely set value, it scatters across arbitrary countries. Both produce a map nobody questions, because it looks full. And the IP address is no marginal technical detail: the General Data Protection Regulation names IP addresses explicitly among the online identifiers that may be associated with natural persons (GDPR, recital 30). What follows for the design of a measurement setup is covered in the article on privacy-compliant website analytics.
- Origin: a country map is only as good as the address it was derived from. If that is your own hop, it shows your own location and no visitor at all.
- Return visits: methods that join sessions without cookies use the address as one building block. A collective address merges arbitrarily many visitors, an invented one splits a single visitor arbitrarily often.
- Bot detection: whether a request originates in a data centre is decided by the network class of the address. With the wrong entry, the method judges the network class of your own proxy. The same caution applies in reverse to the search engine crawler: whether it is genuine is decided by resolving the name, not by the user agent — covered in the article on detecting cloaking.
- Access requests: when a person asks what is stored about them, it must be traceable which address was kept and why. How to answer such a request cleanly is covered in the article on access requests under Article 15.
An address that does not come from your own part of the chain is not a measurement, it is a statement made by the other side. That difference decides whether a report is a basis for decisions or a claim with a timestamp.
— XICTRAQ
Setting it once is not enough
A trust boundary is a snapshot of the infrastructure. It loses validity when a layer is placed in front of it, when an address range changes, or when a delivery network is switched on that was not part of the chain before. None of that raises an error: the application keeps running, the logs keep filling, only the reference is gone. That is why the setting needs a recurring counter-check with a known bad value alongside it – exactly what the last command in the box above does. How to schedule such checks ahead of traffic peaks is covered in the article on Q4 traffic peaks.
For ongoing measurement this means: the address from which origin, network class and bot score are derived should come from your own part of the chain and be identifiable as such. XICTRAQ evaluates page views without cookies and derives country, network class and bot score from that same address, so a deviation becomes visible instead of spreading quietly across every report – see cookieless analytics and the pricing overview. The trust boundary itself remains your decision; a tool can keep it visible, but it cannot draw it for you.
This article draws on: MDN (X-Forwarded-For, sections on security, parsing and selecting an IP address), RFC 7239 (Forwarded HTTP Extension, sections 6.3, 7.4, 8.1 and 8.2), nginx (ngx_http_realip_module), Apache HTTP Server (mod_remoteip), MITRE (CWE-348, Use of Less Trusted Source), NVD (full-text search for X-Forwarded-For), Web Almanac 2025 (CDN chapter, figures 15.1 and 15.3), the General Data Protection Regulation (recital 30) and, as a secondary source, a 2022 technical article from adam-p.ca. The figures quoted are point-in-time values and may change; every number in this article is shown with its source.
Which entry from X-Forwarded-For should I use?
Why does my log show the load balancer's address?
set_real_ip_from names the trusted addresses that are known to send correct replacement addresses (nginx). Without that declaration the internal address stays in place.Can a visitor really set the header themselves?
What is the difference between X-Forwarded-For and Forwarded?
Is real_ip_recursive on enough on its own?
off; with recursive search disabled nginx replaces the original address with the last address in the field, with recursive search enabled by the last non-trusted address (nginx). If not every layer of your own is declared through set_real_ip_from, the recursive search stops at the first unknown address it meets. The original address remains available in $realip_remote_addr and works well as a counter-check (nginx).