The X-Forwarded-For header
How X-Forwarded-For is ordered, why the leftmost entry is attacker-controlled, and the right-to-left trusted-proxy walk that resolves the real client IP.
Key points
X-Forwarded-Foris a comma-separated list ordered client first: the leftmost entry is the one the client could have forged, the rightmost is the address of the proxy that spoke to your last hop.- The only correct resolution is to walk the list right to left, discarding entries while they are in your trusted set, and take the first entry that is not.
- Each proxy appends the address of the peer it received from, not its own, so the final proxy's own IP never appears in the header.
- nginx's
real_ip_recursivedefaults tooff, which takes the rightmost entry unconditionally and is wrong for any chain longer than one proxy. - Duplicate
X-Forwarded-Forheader fields are legal and are joined differently by nginx, Go, Node.js and the Servlet API, which is a spoofing vector on its own.
X-Forwarded-For is a request header carrying a comma-separated list of IP addresses, ordered oldest first: the leftmost value is what the first proxy in the chain saw as its client, and each subsequent proxy appends the address of the peer it received the request from. It is a de facto convention that originated in Squid, never went through the IETF standards process, and has no single authoritative grammar. The correct way to read it is to start from the right, discard entries while they belong to proxies you operate or trust, and take the first entry that does not; the leftmost entry is under the attacker's control unless you have proved otherwise.
| Property | Value |
|---|---|
| Field name | X-Forwarded-For (case-insensitive) |
| Value | Comma-separated list, one entry per hop that chose to append |
| Ordering | Left = closest to the client, right = closest to you |
| Entry format | Bare IPv4, bare IPv6, or ip:port, with no agreed rule |
| Trustworthy portion | Only the entries appended by hops you control |
| Standard | None. Forwarded (RFC 7239) is the standardised replacement |
The resolution procedure, stated once#
- Define the trusted set: every hop address or CIDR between the internet and the process doing the resolution, and nothing else.
- Start at the TCP peer of the current connection. If it is not in the trusted set, stop; the peer is the client and
X-Forwarded-Formust be ignored entirely. - Otherwise read the list right to left. While the current entry parses as an IP and is in the trusted set, discard it and step left.
- The first entry not in the trusted set is the client IP.
- If you run out of entries, the client is the last trusted address discarded, meaning one of your own proxies originated the request.
Steps 2 and 3 are one loop, not two. The connection peer is simply the entry to the right of the rightmost header entry. You can experiment with chains and trusted sets in the client IP resolver, which implements exactly this walk.
The offset that trips people up#
Each proxy appends the address it is receiving from, so the header describes the links in the chain, not the nodes. A request crossing three proxies arrives with three entries only if all three append, and the third proxy's own address is not among them: nothing downstream of it wrote it down. The origin learns that address from the TCP connection instead.
The consequence: the trusted set must contain both the header entries and the direct peer. Teams that add the load balancer subnet but forget the sidecar or ingress controller in front of it end up with a resolver that silently returns an internal address.
Why it is de facto and Forwarded is not#
X-Forwarded-For predates any attempt to standardise it. By the time RFC 7239 defined Forwarded in June 2014, every proxy, CDN and web framework already emitted and consumed the X- form, and RFC 7239 acknowledges X-Forwarded-For as the deployed prior art it replaces. RFC 6648 had separately deprecated the X- prefix for new headers, which is why the standardised field carries no prefix. The differences that matter operationally:
| Concern | X-Forwarded-For | Forwarded (RFC 7239) |
|---|---|---|
| Grammar | None normative | ABNF in RFC 7239 section 4 |
| Carries protocol/host/port | No, needs sibling X-Forwarded-* headers | Yes, proto, host, by in one element |
| IPv6 form | Implementation-defined | for="[2001:db8::1]:4711", quoting mandatory |
| Obfuscated identifiers | Not defined | for=_hidden, defined in section 6.3 |
| Parsing cost | Split on comma | Full quoted-string parser |
| Ecosystem support | Universal | Partial |
The full grammar and the reasons adoption stalled are covered in the Forwarded header (RFC 7239). In practice you will parse both, and you will parse X-Forwarded-For far more often.
IPv6, ports, and the inconsistency#
Because there is no grammar, there is no agreed way to write an IPv6 address or a port. All of the following are emitted by real software:
X-Forwarded-For: 203.0.113.7
X-Forwarded-For: 203.0.113.7:52918
X-Forwarded-For: 2001:db8::1
X-Forwarded-For: [2001:db8::1]:52918
X-Forwarded-For: 2001:db8::1, 203.0.113.7A bare IPv6 address contains colons, so a parser that splits on : to strip a port mangles it, and a parser that assumes brackets fails on the bare form. The safe rule: if the entry starts with [, parse to the matching ] and treat anything after ]: as a port; otherwise treat exactly one : as an address/port split and more than one : as a bare IPv6 address.
nginx builds the header from $proxy_add_x_forwarded_for, which uses $remote_addr and never includes a port; some managed load balancers do include it. Parse defensively and discard the port, which is useless for authorisation or rate limiting anyway.
Leftmost versus rightmost, stated crisply#
- Leftmost is the value that is semantically the client, and is the value an attacker sets by simply sending the header themselves. Reading it without a trust walk is a direct authorisation bypass for anything IP-based.
- Rightmost is the value your closest proxy appended, so it is trustworthy but it is the proxy's address, not the client's, whenever more than one proxy appends.
Neither is the answer. The answer is "the leftmost entry that is not vouched for by a trusted hop", which you find by starting at the right. This is the whole reason configuring trusted proxies is a prerequisite and not an optimisation, and why client IP spoofing through proxies is nearly always a trust-configuration bug rather than a protocol bug.
Worked example: CDN, then ALB, then nginx, then app#
Client 203.0.113.7 reaches a Cloudflare edge at 172.68.1.1, which forwards to an AWS Application Load Balancer node at 10.0.1.20, then nginx at 10.0.2.30, then the application at 10.0.3.40. The client sends a forged header on the way in.
| Hop | Receives from | X-Forwarded-For as received | X-Forwarded-For as sent on |
|---|---|---|---|
| Cloudflare edge | 203.0.113.7 | 1.2.3.4 (forged) | 1.2.3.4, 203.0.113.7 |
| AWS ALB | 172.68.1.1 | 1.2.3.4, 203.0.113.7 | 1.2.3.4, 203.0.113.7, 172.68.1.1 |
| nginx | 10.0.1.20 | 1.2.3.4, 203.0.113.7, 172.68.1.1 | 1.2.3.4, 203.0.113.7, 172.68.1.1, 10.0.1.20 |
| Application | 10.0.2.30 | 1.2.3.4, 203.0.113.7, 172.68.1.1, 10.0.1.20 | n/a |
The application's trusted set is 10.0.0.0/8 plus the published Cloudflare ranges. The walk from the right:
- TCP peer
10.0.2.30(nginx): trusted, continue. 10.0.1.20(the ALB, appended by nginx): trusted, continue.172.68.1.1(the Cloudflare edge, appended by the ALB): trusted, continue.203.0.113.7: not trusted. This is the client.
1.2.3.4 is never reached. The ALB's address appears only because nginx appended it, and nginx's own 10.0.2.30 appears nowhere. The forged entry survives to the origin because nothing strips it, so any downstream system reading the leftmost value gets the attacker's choice.
What popular software does by default#
| Software | Setting | Default | Entry it selects |
|---|---|---|---|
nginx ngx_http_realip_module | set_real_ip_from, real_ip_header, real_ip_recursive | Module does nothing until set_real_ip_from is configured; real_ip_recursive is off | With off: the last (rightmost) address in the header, with no trust check on the header entries themselves (only the connection peer is checked against set_real_ip_from). With on: the rightmost address not matching a set_real_ip_from range |
| HAProxy | option forwardfor | Off; when enabled, appends src and sets X-Forwarded-For | Selects nothing. src remains the TCP peer. Use req.hdr_ip(X-Forwarded-For,-1) for the rightmost entry (negative indices count from the end) |
| Envoy | use_remote_address, xff_num_trusted_hops | use_remote_address false, xff_num_trusted_hops 0 | With use_remote_address true, the address xff_num_trusted_hops positions from the right end of the list; 0 means the connection peer itself |
| Cloudflare | CF-Connecting-IP | Always set on proxied traffic, overwritten on every request | A single client address in its own header; X-Forwarded-For is appended to, forged prefixes included |
| AWS ALB | routing.http.xff_header_processing.mode | append | Appends the connection peer. Alternative modes preserve and remove change what reaches the target |
Apache httpd mod_remoteip | RemoteIPHeader, RemoteIPInternalProxy, RemoteIPTrustedProxy | Inactive until RemoteIPHeader is set | Walks right to left, consuming addresses that match the internal or trusted proxy lists, and stops at the first that does not |
| Traefik | entryPoints.<name>.forwardedHeaders.trustedIPs / .insecure | Empty list, insecure false | Incoming X-Forwarded-* from an untrusted peer are removed and regenerated from the connection |
| Caddy | servers.trusted_proxies, client_ip_headers (2.7 and later) | Empty; client_ip_headers defaults to X-Forwarded-For | With no trusted proxies, {client_ip} equals the connection peer. With them set, a right-to-left walk skipping trusted addresses |
Two rows deserve emphasis. nginx's real_ip_recursive off default is the commonest source of wrong client IPs behind a CDN: it takes the rightmost entry regardless of trust, so behind a CDN plus a load balancer it returns the CDN edge address every time. Envoy's xff_num_trusted_hops default of 0 is safe precisely because it means "trust nothing in the header", which is right but is frequently misread as "trust one hop".
Duplicate header fields#
X-Forwarded-For is a comma-separated list header, so RFC 9110 permits a recipient to combine multiple field lines with the same name into one, and permits a sender to split one into several. A client can therefore send:
GET /account HTTP/1.1
Host: app.example.com
X-Forwarded-For: 10.0.0.1
X-Forwarded-For: 198.51.100.9What happens next depends entirely on the joining behaviour of every component in the path:
| Component | Behaviour with repeated X-Forwarded-For fields |
|---|---|
| nginx | Stores all occurrences and exposes them joined with ", " in $http_x_forwarded_for, so $proxy_add_x_forwarded_for emits one collapsed header |
Go net/http | Keeps a []string; Header.Get returns only the first value, Header.Values returns all |
Node.js http | Joins duplicate field lines with ", " into a single req.headers value |
| Java Servlet API | getHeader returns the first field line; getHeaders returns an enumeration of all |
The attack follows directly: if any hop preserves the two field lines separately and the application uses a first-value getter, the application reads the attacker's field line while the trusted proxy appended to the other one. The trust walk then runs over data no proxy ever touched. Collapse at the trust boundary: delete every inbound X-Forwarded-For at your outermost proxy before writing the one you control, and in the application always use the all-values accessor and join with commas before parsing.
Failure modes#
Every request logs the load balancer's address. Symptom: access logs show a handful of RFC 1918 addresses and rate limiting throttles all users together. Cause: no trust configuration at all, or nginx realip configured with real_ip_recursive off behind two or more appending hops. Fix: add every hop address to set_real_ip_from and set real_ip_recursive on.
Rate limiting is trivially bypassed. Symptom: a single source sustains far more requests than the limit. Cause: the limiter keys on the leftmost X-Forwarded-For entry. Fix: key on the resolved client IP from the right-to-left walk, and reject requests whose header is unparseable rather than falling back to the leftmost value.
Geo-IP and allowlists match on an internal address. Symptom: 403 for users who should be allowed, or geolocation resolving to your own cloud region. Cause: the allowlist is evaluated against the connection peer rather than the resolved client. Fix: resolve once at the edge of the application, then evaluate, rather than resolving independently in three middlewares.
Header grows without bound. Symptom: 400 from an upstream with a message about request header size, often after adding a proxy tier. Cause: each hop appends, and a retry loop or a self-referential proxy_pass adds entries repeatedly. Fix: cap or reset the header at the trust boundary and check header and body size limits at the proxy.
Direct-to-origin traffic bypasses the CDN. Symptom: X-Forwarded-For present but the connection peer is not a CDN address. Cause: the origin is reachable on the public internet. Fix: firewall the origin to the CDN ranges. Until you do, the header from that path is entirely attacker-authored, and no parsing strategy can rescue it. If you need the client address without trusting a header, the PROXY protocol carries it out of band at the TCP layer.
Frequently asked questions#
Is the first or last IP in X-Forwarded-For the client?#
Neither on its own. The first (leftmost) entry is semantically the client but can be forged by the client, and the last (rightmost) is the address of the proxy that connected to your last hop. The client IP is the first entry, reading right to left, that is not in your set of trusted proxy addresses.
Can X-Forwarded-For be spoofed?#
Yes, trivially. Any client can send the header, and proxies append rather than replace, so a forged value survives to the origin as the leftmost entry. It becomes unforgeable only for the portion of the list appended by hops you trust, which is why the trusted set has to be configured explicitly.
Should I use X-Forwarded-For or the Forwarded header?#
Consume both, and emit X-Forwarded-For for compatibility plus Forwarded where your stack supports it. Forwarded has a real grammar and carries protocol and host in one element, but support is patchy, so anything that reads only Forwarded will break behind common proxies.
Why does nginx give me the wrong client IP behind Cloudflare?#
Almost always because real_ip_recursive is left at its default of off, which takes the rightmost header entry without checking trust. Behind a CDN plus another load balancer that yields the CDN's address. Set real_ip_recursive on and list every hop range in set_real_ip_from.
What does X-Forwarded-For look like with IPv6?#
There is no agreed form. Implementations emit bare 2001:db8::1 and bracketed [2001:db8::1]:52918, and both appear in the wild. Parse the bracketed form by matching the closing bracket, and treat any entry with more than one colon and no brackets as a bare IPv6 address.
Do I need X-Forwarded-For if I already have X-Real-IP?#
They solve different problems. X-Real-IP is a single value each hop overwrites, so it loses the chain and cannot be validated beyond one hop. See X-Real-IP, X-Forwarded-Proto, Host and Port for when the single-value form is safe.
Should the outermost proxy strip inbound X-Forwarded-For?#
Yes, if it is genuinely the outermost hop and clients connect to it directly. Deleting the inbound header and writing a fresh one containing only the connection peer guarantees every downstream entry is trustworthy. Do not do this if a CDN or another proxy sits in front; you would discard the real client address.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- RFC 7239: Forwarded HTTP Extension
- RFC 9110: HTTP Semantics
- RFC 6648: Deprecating the "X-" Prefix and Similar Constructs
- nginx ngx_http_realip_module
- HAProxy configuration manual
- Envoy HTTP connection manager headers
- Apache httpd mod_remoteip
- AWS Application Load Balancer X-Forwarded headers
- Cloudflare HTTP request headers
- Caddy global options
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.