Skip to content
GDPR compliant. Hosted in DE.
14-day free trial →
SecurityProxyLogging
Security 14 min read 18 Sept 2026

Real Visitor IP: Getting X-Forwarded-For Right

Behind a proxy, load balancer or CDN your log holds the wrong address. How to set the trust boundary and read X-Forwarded-For reliably.

X

XICTRAQ

XICTRAQ

SecurityProxyLoggingAnalytics
35 %
of HTML requests measured on mobile come from a CDN (Web Almanac 2025)
71 %
of the top 1,000 sites use a CDN (Web Almanac 2025)
195
entries in the NVD vulnerability database mention X-Forwarded-For (NVD)
Trust boundary in the proxy chainWhich entry in X-Forwarded-For holds up and which does nottrusted segmentClientsets the headerhowever it likesForeign proxyappends an addressuncheckedOwn load balancerappends the verifiedaddressApplicationreads the listfrom the rightX-Forwarded-For as the application sees it203.0.113.7set by the client198.51.100.4set by the foreign proxy192.0.2.55set by our own load balancerOnly what our own load balancer appended itself holds up.HTML via CDN35 %of HTML requestsmeasured on mobileLarge sites71 %of the top 1,000sites use a CDNVulnerabilities195entries mentionthis header
Behind every proxy sits the same question: who owns the address that just landed in the access log? As soon as a load balancer, a reverse proxy or a content delivery network stands between visitor and application, the server only sees the last hop. Among the top 1,000 sites, 71 percent sit behind a CDN, and across all mobile requests measured, 35 percent of HTML requests come from one (Web Almanac 2025) – so the question is not an edge case but the normal state of things. The answer lives in the X-Forwarded-For header, a de-facto standard for identifying the originating address of a client that reaches a server through a proxy (MDN). Except that this header is not a measurement, it is a chain of claims: every node along the way appends an entry, and whatever the visitor writes into it stands at the very front. Draw no boundary between your own and the foreign part of that chain, and foreign addresses end up in block lists, counters and reports – typically noticed only when a block hits the wrong person.

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.

Two questions, two different answers

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.

MethodWhich address it yieldsWhat it is good forWhere it breaks
Connection peer addressthe last hopdeployments without any proxybehind a load balancer or CDN, one single unchanging value
Leftmost entrywhatever the first node reportedcoarse reach reporting without consequencesfreely set by the visitor
Rightmost entrywhatever the last proxy appendedexactly one proxy layer of your ownwith two layers of your own it yields an internal address
Count of your proxiesposition from the right, count minus onea fixed, known chainone extra layer shifts the position silently
List of your proxiesfirst address from the right that is not configureda varying number of layersstale address ranges after a migration
The CDN's own fieldthe value the provider sets itselfwhen that provider overwrites the field reliablywithout 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.

The boundary belongs on a checklist, not in someone's memory

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).

Multiple headers form one list

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.

IPv6 belongs in brackets

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.

Obfuscated identifiers are not addresses

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.

Translation is not a copy

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_from without a matching real_ip_header reads 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 on plus 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.
$ nginx -T 2>/dev/null | grep -E 'set_real_ip_from|real_ip_header|real_ip_recursive'
set_real_ip_from 192.0.2.0/24; real_ip_header X-Forwarded-For; real_ip_recursive on;
$ curl -sS -o /dev/null -H 'X-Forwarded-For: 203.0.113.7' https://www.example.com/
$ tail -n 1 /var/log/nginx/access.log | awk '{print $1}'
198.51.100.4
$ awk '$1 ~ /^203[.]0[.]113[.]/ {n++} END {print n+0}' /var/log/nginx/access.log
0

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.

visitor_ip.py
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.

Sources and studies

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?
It depends on the purpose. For anything with consequences – blocking, rate limiting, access control – the rule is to use only addresses added by a trusted proxy (MDN). In practice that means walking the list from the right and skipping your own proxy ranges. For reach reporting without consequences the leftmost valid, non-private entry may be used, but explicitly only where spoofed values have no negative impact (MDN).
Why does my log show the load balancer's address?
Because the server as a rule logs the address of the peer it actually talked to, and with a proxy in front that is the proxy's address (MDN). In nginx the ngx_http_realip_module addresses this: 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?
Yes, and that is the starting point for the whole question. If any proxy is malicious or misconfigured, any part of the header not added by a trusted proxy may be spoofed or may have an unexpected format (MDN). The Apache documentation states the same for its module: it is trivial for the remote user agent to impersonate another one (Apache HTTP Server). So the question is as a rule not whether someone can forge the value, but whether a decision depends on it.
What is the difference between X-Forwarded-For and Forwarded?
X-Forwarded-For is a de-facto standard, Forwarded is the standardised version from RFC 7239 and is used much less frequently (MDN). Forwarded can carry more parameters in a single field and requires quotes and square brackets for IPv6 addresses, which may be absent in X-Forwarded-For (RFC 7239). The switch as a rule changes nothing about the trust problem: Forwarded cannot be relied upon to be correct either, because every node along the way can modify it (RFC 7239).
Is real_ip_recursive on enough on its own?
Only together with complete address ranges. The default is 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).
Is an IP address personal data?
The General Data Protection Regulation names IP addresses explicitly as an example of online identifiers that may be associated with natural persons and that leave traces which can be combined into profiles (GDPR, recital 30). Two things follow for practice: the address you store should be the one that was actually measured, and keeping it needs a stated purpose. A wrong address is not a smaller data protection problem than a correct one, it is an additional one.
Start in 60 seconds

Let XICTRAQ watch your website.

Permanent free plan available. No credit card, no installation, no hidden fees.