The X-Forwarded-For chain: trust, spoofing, and how edges resolve real IPs
Send this to almost any web app that sits behind a proxy, with no proxy in front of it at all:
GET /admin HTTP/1.1Host: example.comX-Forwarded-For: 127.0.0.1A surprising number of them will treat you as localhost. Not because of a clever exploit, but because the application read a header that the client wrote, and believed it. That is the whole problem in one request. X-Forwarded-For exists to recover information the network throws away when a request passes through a proxy. The catch is that the same header is just text in the request, editable by anyone who can open a socket, and telling the trustworthy bytes from the forged ones is harder than it looks. People get it wrong constantly, in frameworks you have heard of, in ways that turn into authentication bypasses and rate-limit holes.
This post is about that chain: what the header actually carries, how each proxy appends to it, why the left end is a lie waiting to happen, how the standardized Forwarded header from RFC 7239 tried to clean it up, and the only two resolution strategies that are safe. Then the failure modes, because they are instructive. We will walk through real advisories where a rightmost-versus-leftmost mistake became a critical CVE, the multiple-header parsing trap that catches careful people, and the lower-layer alternatives (the PROXY protocol, CDN-specific headers) that exist because the HTTP header is fundamentally untrustworthy on its own.
What the network throws away
A direct HTTP connection is simple about identity. The server’s TCP stack knows the peer address of the socket, full stop. That is the source IP of the packets, and short of a spoofed-source flood that can’t complete a handshake, it is real. The application reads it from the connection, logs it, rate-limits on it, and moves on.
Put a reverse proxy in front and that guarantee evaporates. Now the origin server’s socket peer is the proxy, every single time, for every client. The TCP source address the origin sees is 10.0.0.7 or whatever internal address the load balancer uses, identical for a visitor in Lagos and a visitor in Oslo. The real client address terminated one hop earlier, at the proxy, and the origin never saw the packets that carried it. This is not a bug. It is what terminating a connection means. The proxy accepts the client’s TCP connection, opens its own separate connection to the backend, and copies bytes between them. The two connections share no addressing.
So the proxy has information the origin needs and can’t otherwise get. The fix the industry reached for, long before anyone standardized it, was to have the proxy write the client’s address into a request header before forwarding. Squid shipped X-Forwarded-For in the late 1990s, the X- prefix marking it as nonstandard, and it spread by imitation until it was everywhere. There was never an RFC behind the original. There was a convention, copied from one proxy to the next, with all the inconsistency that implies.
The shape of the convention is a comma-separated list. With one proxy in the path you get a single address. With a chain, each proxy appends, and the list grows left to right in the order the request traveled.
*Each proxy appends the address it received the connection from, so the list reads client-first, left to right. The last proxy's own address is not in the header; it is the origin's TCP peer.*Read that diagram carefully, because the asymmetry at the right end is the key to everything that follows. Proxy B appends proxy A’s address (10.0.0.2), the address B’s socket actually saw. B does not append its own address. B’s address is what the origin reads from the connection. So the header always stops one short: the rightmost listed IP is the second-to-last hop, and the genuinely-last hop lives in the TCP layer, outside the header entirely. RFC 7239 makes the same point for its standardized version: “the last proxy in the chain is not part of the list.”
Why the left end is a lie
Here is the uncomfortable fact. The proxy that wrote the leftmost entry copied it from the connection it received. If your edge is the first proxy in the path, that connection came from the real client, and the leftmost value is the real client address. But the proxy has no way to know whether the client already put an X-Forwarded-For header in the request before connecting. Most proxies, by default, append to whatever was there. They do not strip it. They do not validate it. They trust that the list they received is a faithful record and add one entry.
A client can send a fully populated X-Forwarded-For header in its very first request. There is nobody upstream to have written it honestly, so every entry in it is whatever the client typed. When the edge proxy appends the real client address to the end of that fabricated list, the result is a header where the left portion is pure fiction and only the rightmost entry, the one the edge added, corresponds to anything real.
This is exactly what happens at a real load balancer. AWS Application Load Balancer, by its documented behavior, appends to the incoming X-Forwarded-For rather than replacing it. Send a request with X-Forwarded-For: 1.2.3.4 and the ALB forwards X-Forwarded-For: 1.2.3.4, <your real address>. The 1.2.3.4 is yours, made up, untouched. The security researcher Adam Pritchard documented this precisely in a widely-cited 2022 write-up, demonstrating with curl that the ALB passes the injected value through “unchanged and unvalidated.” That is not a flaw in the ALB. Appending is the correct, lossless behavior for a proxy. The flaw is downstream, in any application that reads the left end of that list and believes it.
So if you take the first IP in X-Forwarded-For, you are reading client-controlled input. MDN states the consequence without hedging: any security use of the header “must only use IP addresses added by a trusted proxy,” and “leftmost (untrusted) values must only be used for cases where there is no negative impact from using spoofed values.” Logging the leftmost value for rough analytics, fine, nobody dies if it is forged. Rate-limiting on it, geo-fencing on it, deciding is this localhost on it, those are the cases where a spoofed value is a vulnerability.
The two safe resolutions
If the left end is untrustworthy, the trustworthy data has to come from the right. Work backward from the rightmost entry, because that entry was written by your own edge proxy, the one piece of the chain you control. From there, peel off hops you trust until you reach the first one you don’t. That first untrusted address is the closest thing to a real client identity you can defend. The two standard methods for doing this differ only in how they decide which hops to trust.
The first method is a trusted-proxy count. You know your topology. Suppose every request that legitimately reaches your origin passes through exactly two proxies you operate, a CDN and an internal load balancer. Then the rightmost two entries in the fully-assembled list were written by infrastructure you trust, and the entry immediately to their left is the first address that came from outside your control. You skip a fixed number of entries from the right and read the next one. MDN’s phrasing: search “from the rightmost by that count minus one,” because the rightmost entry is what the innermost proxy added, and the count tells you how deep your own infrastructure goes.
The second method is a trusted-proxy list. Instead of a count, you configure the IP addresses or CIDR ranges of your own proxies. Walk the list from the right, discarding any address that matches a trusted range, and stop at the first address that does not match. That non-matching address is your answer. This holds up better than a count when your proxy addresses are stable but the depth of the chain varies, and it is what nginx’s ngx_http_realip_module, Apache’s mod_remoteip, and most serious frameworks implement when configured properly.
There is a subtlety the diagram glosses. The first untrusted address might not be the actual end user. If a request legitimately passes through a third-party proxy you do not operate, the address you land on is that proxy, not the human behind it. MDN is honest about this: the resolved address “may belong to an untrusted intermediate proxy rather than the actual client, but it is the only IP suitable to identify a client for security purposes.” For rate-limiting and access control that is fine. You are identifying the nearest entity you can hold accountable, not necessarily a person. For attribution or geolocation you may want the leftmost address instead, accepting that it can be forged. The two goals pull in opposite directions, and conflating them is its own bug.
A critical configuration point sits underneath all of this. The rightmost-trusted approach is only safe if your origin cannot be reached except through your proxies. If an attacker can open a TCP connection straight to the origin, bypassing the edge, then the origin’s own edge proxy never runs, the attacker writes the entire X-Forwarded-For themselves, and the rightmost entry is as forged as the leftmost. MDN states the precondition flatly: 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 IP list can be considered trustworthy.” Lock the origin to your proxy’s source addresses at the firewall, or none of the parsing logic means anything. This is the same trust boundary that makes reverse, forward, and transparent proxies behave so differently from a security standpoint, and the same boundary that load balancers straddle when they distribute connections across a backend pool.
RFC 7239 and the Forwarded header
The X- family grew without coordination, and it shows. There is X-Forwarded-For for the address, X-Forwarded-Proto for the original scheme, X-Forwarded-Host for the original Host header, X-Forwarded-Port, and a dozen vendor variants. Each is a separate header with its own parsing rules and its own way of being wrong. In 2014 the IETF published RFC 7239, “Forwarded HTTP Extension,” to fold the common cases into one standardized header with a defined grammar.
The Forwarded header carries the same information as a list of semicolon-separated parameter pairs. The defined parameters are for (the node that made the request to the proxy), by (the proxy’s own user-agent-facing interface), host (the original Host value), and proto (the original scheme). A single hop looks like Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43. A chain comma-separates multiple elements, with the first element added by the first proxy, same left-to-right ordering as X-Forwarded-For.
Two design choices in RFC 7239 are worth knowing. First, because IPv6 addresses contain colons that would collide with the node:port separator, the spec requires them quoted and bracketed: Forwarded: for="[2001:db8:cafe::17]:4711". The quoting is mandatory, which means a parser that splits naively on colons will mangle IPv6 in a way it never would for X-Forwarded-For. Second, the spec defines obfuscated node identifiers. A proxy that does not want to disclose internal addresses can emit a token with a leading underscore, like Forwarded: for="_gazonk", and the ABNF (obfnode = "_" 1*( ALPHA / DIGIT / "." / "_" / "-")) keeps that distinguishable from a real address or a port. The RFC actually recommends obfuscation as the default, treating raw internal IPs as information you should not leak by reflex.
What RFC 7239 does not do is make the data trustworthy. The security considerations section says so directly: the Forwarded header “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.” The same trusted-proxy discipline applies. Standardizing the syntax did not change the trust model one bit; it only gave parsers a grammar to agree on. Adoption has been tepid. MDN notes the Forwarded header is “much less frequently used” than the X-Forwarded-For it was meant to replace, and a decade after publication most infrastructure still speaks the older, messier dialect. HAProxy can emit Forwarded, and some frameworks read it, but the network as a whole never moved.
When it breaks: real advisories
The theory is simple enough that you might expect the bugs to be rare. They are not. The same mistake, reading an attacker-controlled position in the chain for a security decision, recurs across mature, well-staffed projects. A few recent ones, all with the same shape and different blast radii, make the pattern concrete.
The most severe is Apache ZooKeeper, CVE-2024-51504, scored CVSS 9.1 critical. ZooKeeper’s admin server has an IPAuthenticationProvider that can gate administrative commands by client IP. It read the client address from the X-Forwarded-For header. Since that header is client-writable, an attacker could set it to an allowlisted address and bypass the IP-based authentication entirely, reaching commands like snapshot and restore. The classification is CWE-290, authentication bypass by spoofing, and it was fixed in ZooKeeper 3.9.3 (the flaw spanned 3.9.0 through 3.9.2). The bug here is not even a left-versus-right subtlety. It is trusting the header at all on a server reachable without a proxy in front of it.
A subtler one is OpenClaw, patched in version 2026.2.21. Here the developers had done the right thing in spirit: they only trusted X-Forwarded-For when the request came from a configured trusted proxy. But the parsing took the leftmost value from the header rather than walking from the right. Against a proxy that appends (as nearly all do), an attacker prepends a forged address, the trusted proxy appends the real one, and the leftmost-reading code picks the forgery. The advisory describes the impact as undermining “auth rate-limit identity” and “local/private classification,” which is to say an attacker could masquerade as a trusted internal address. The weakness classifications are CWE-345 and CWE-807, reliance on untrusted inputs in a security decision. No CVE was assigned, and the severity was rated low, but the mechanism is the exact textbook trap: right idea, wrong end of the list.
Rate-limit bypasses are the most common variant because rate limiters key on client IP almost by definition. Litestar, the Python framework, carried CVE-2025-59152: its RateLimitMiddleware trusted X-Forwarded-For unconditionally and used the value as part of the client identifier. An attacker rotating the header through fresh values gets a fresh rate-limit bucket each time, so the limit never bites. Patched in Litestar 2.18.0, scored 5.3 moderate. Mastodon had a structurally identical issue in its Rails stack, where spoofing X-Forwarded-For or Client-Ip let an attacker dodge rate limits that were partly enforced by exempting 127.0.0.1. The pattern is so reliable that it shows up in bug-bounty reports against production login endpoints, where rotating the header turns a five-attempts-per-IP limit into unlimited brute force. If you are building a limiter yourself, the discipline around rate limiting and 429 backoff starts with not trusting the identity you are limiting on.
The multiple-header trap
Even when you resolve from the right and lock down the origin, one parsing detail catches careful engineers. HTTP allows a header field to appear more than once in a single request. The spec says a recipient may combine repeated fields into one comma-separated list, and for X-Forwarded-For that combining is exactly what you must do. MDN is explicit: the addresses across multiple X-Forwarded-For headers “must be treated as a single list, starting with the first IP address of the first header and continuing to the last IP address of the last header,” and “it is insufficient to use only one of multiple X-Forwarded-For headers.”
The trap is that many HTTP libraries hand you only one of the repeated headers. Go’s Header.Get() returns the first occurrence. Other stacks return the last. So picture an attacker who sends two X-Forwarded-For headers. Your edge proxy appends the real client address to the last one. Your application calls Get(), reads the first header, and gets a value the attacker fully controls, never seeing the second header where the real address landed. You did everything else right, walked from the right of the list you were handed, and still read a forgery, because the library silently dropped the header that mattered.
There is a related formatting hazard. The RFC permits comma separation but says nothing about spaces, so producers differ. AWS ALB writes comma-space; some CDNs write bare commas. A parser that splits on the literal string ", " will treat a bare-comma list as a single malformed token and either crash or, worse, silently produce one giant “address” that is never anyone’s real IP. Split on the comma and trim whitespace per element. These are unglamorous details, but they are where the gap between “looks correct” and “is correct” lives.
What the CDN headers actually promise
The big edge providers sidestep some of this by writing their own single-value headers that they fully control. Cloudflare sets CF-Connecting-IP with the address of the client connecting to Cloudflare, one IP, no list to misparse. On Enterprise plans it also sets True-Client-IP with the same value, a header name Cloudflare chose for compatibility with Akamai’s identically-named header. Cloudflare still appends to X-Forwarded-For in the conventional way: if no header is present it mirrors CF-Connecting-IP, and if one is present it appends the address connecting to Cloudflare.
The single-value design removes the parsing ambiguity but not the trust requirement. Cloudflare’s own documentation warns that these headers can be spoofed if you accept traffic from anywhere other than Cloudflare: an attacker who reaches your origin directly can set CF-Connecting-IP or True-Client-IP to anything. The guidance is the same trust boundary as before, restated for a branded header. Only accept connections from Cloudflare’s published address ranges, and only then is the header meaningful. The header name changes; the rule does not. The same caution applies whenever you stack two CDNs, because the inner one will happily forward a True-Client-IP the outer one never validated.
One Cloudflare wrinkle worth flagging for anyone parsing these in 2026: Pseudo IPv4. To let IPv4-only origins cope with IPv6 clients, Cloudflare can hash an inbound IPv6 address into a Class E IPv4 address (the 240.0.0.0/4 range) and place it in CF-Pseudo-IPv4, or in “Overwrite Headers” mode replace CF-Connecting-IP and X-Forwarded-For with that pseudo address while preserving the real one in CF-Connecting-IPv6. If you see a 240.x address where you expected a routable client, that is why. Treating it as a real source IP, or rejecting it as a bogon, are both mistakes the feature invites.
The lower-layer answer: PROXY protocol
Every problem so far traces to one root: X-Forwarded-For is an HTTP header, so it lives in the same request body the client controls, and a proxy has to actively and correctly rewrite it to be safe. The cleaner fix is to move client-IP preservation below HTTP entirely, where the client cannot reach it.
That is what the PROXY protocol does. Designed by Willy Tarreau for HAProxy, it has the proxy prepend a small header to the TCP stream, before any application bytes, carrying the original source and destination addresses and ports. The backend reads that prefix first, learns the true client address, and then processes the rest of the connection normally. Because the prefix is written by the proxy onto a fresh backend connection the client never touches, the client has no way to forge it. Version 2 uses a binary encoding and can carry extra metadata such as TLS details. And because it operates at the TCP layer, it works for any TCP-based protocol, not just HTTP, which is why it is the standard way to preserve client IPs through an AWS Network Load Balancer or a layer-4 HAProxy in front of a non-HTTP service.
PROXY protocol is not magic and inherits the same trust boundary in a new form. If a backend accepts the PROXY header from any source, an attacker who connects directly can prepend a forged one and claim any address. The rule, identical in spirit to the HTTP case, is to accept the PROXY header only on connections from your trusted proxies. HAProxy’s documentation is blunt about this: enable accept-proxy only on listeners that genuinely sit behind a proxy, never on anything internet-facing. What changes is the failure mode. With X-Forwarded-For, forgetting the trust boundary leaves a quiet, exploitable hole. With PROXY protocol, a misconfiguration usually breaks the connection loudly, because a backend expecting the prefix and not getting it tends to reject the connection outright. Loud failure is a feature.
Closing: the header was never the source of truth
Strip away the parsing details and one fact remains. The real client address lives in exactly one place that an attacker cannot edit: the source address of the TCP connection your trust boundary terminates. Every mechanism in this post, the leftmost entry, the rightmost-trusted walk, the Forwarded header, the CDN’s branded single-value headers, the PROXY protocol prefix, is a way of copying that one trustworthy fact forward across a hop that would otherwise lose it. They differ in how forgeable the copy is and how loudly they fail when you wire them wrong. They do not differ in the underlying truth they are trying to relay.
The bugs, then, are never really about X-Forwarded-For. They are about forgetting where the trust boundary is. ZooKeeper trusted a header on a server with no boundary at all. OpenClaw had the boundary but read the wrong end past it. Litestar and Mastodon had no boundary check on the value they keyed their limiters on. In each case the header did exactly what it was designed to do, which is carry whatever someone wrote into it. The defensive posture is small and unglamorous: pin the origin to your proxy’s addresses, decide your trusted depth, walk from the right, merge repeated headers before you parse, and never let a value that crossed an untrusted hop drive a security decision. Do that and the chain is a useful record. Skip any one of those and it is an attacker’s input field with a reassuring name.
Sources & further reading
- IETF (2014), RFC 7239: Forwarded HTTP Extension — the standardized header, its ABNF grammar, the
for/by/host/protoparameters, IPv6 quoting, obfuscated node identifiers, and the security considerations that say the data cannot be relied upon to be correct. - MDN Web Docs (2025), X-Forwarded-For header — current guidance on syntax, the leftmost-is-untrustworthy rule, the trusted-proxy-count and trusted-proxy-list methods, and merging multiple headers into one list.
- Adam Pritchard (2022), The perils of the “real” client IP — the definitive walkthrough of why leftmost is spoofable, the rightmost-trusted algorithm, the multiple-header
Get()trap, and a catalog of frameworks that parse it wrong. - Cloudflare (2026), Cloudflare HTTP headers — exact behavior of
CF-Connecting-IP,True-Client-IP(Enterprise), the append-not-overwriteX-Forwarded-Forrule, andCF-Pseudo-IPv4. - NVD (2024), CVE-2024-51504: Apache ZooKeeper X-Forwarded-For authentication bypass — CVSS 9.1 critical,
IPAuthenticationProvidertrusting a client-writable header, fixed in 3.9.3. - OpenClaw security advisory (2026), Improper X-Forwarded-For parsing allows client IP spoofing — a leftmost-versus-rightmost parsing bug behind a trusted proxy, patched in 2026.2.21, classified CWE-345 and CWE-807.
- Litestar security advisory (2025), X-Forwarded-For header spoofing bypasses rate limiting (CVE-2025-59152) — unconditional trust of the header in the rate limiter, fixed in 2.18.0.
- Mastodon security advisory (2024), Bypassing rate limiting with X-Forwarded-For header — spoofed forwarding headers dodging rate limits in a Rails stack that exempted localhost.
- HAProxy (2024), What is the PROXY protocol? How it preserves client IPs — the TCP-layer alternative to
X-Forwarded-For, v2 binary format, and the requirement to accept it only from trusted proxies. - PortSwigger (n.d.), Spoofable client IP address — scanner-level description of the spoofable-client-IP issue class and its remediation.
- Wikipedia (2025), X-Forwarded-For — history of the Squid-era convention, the
X-family, and its relationship to the standardizedForwardedheader.
Further reading
Slowloris and the slow-attack family: starving a server with patience
Traces the low-bandwidth slow attacks: Slowloris, slow POST (RUDY), and slow read, how each pins a worker thread on thread-per-connection servers, why event-driven servers shrug them off, and what actually times them out.
·23 min readRequest smuggling: how HTTP/1.1 desync attacks exploit parser disagreement
Traces how HTTP/1.1's two ways of measuring a request body let a front-end and back-end disagree on where one request ends, how CL.TE and TE.CL desync turns that into socket poisoning, and what actually fixes it.
·21 min readAnti-bot honeypots: hidden form fields, decoy links, and timing traps
Traces the honeypot technique family used to catch automation cheaply: hidden form fields, off-screen decoy links, and submission-timing checks, plus why each one fails against a browser-driving bot and where the false positives hide.
·24 min read