Client IP

Client IP spoofing through proxies

How X-Forwarded-For spoofing bypasses rate limits, IP allowlists and admin panels, the misconfigurations behind it, and a curl test for your stack.

· 15 min read · How we verify this

Key points

  • X-Forwarded-For is a request header, so any client can set it to any value; it is only meaningful for the segment of the chain your own proxies wrote.
  • The two enabling mistakes are taking the leftmost entry and honouring the header from any source; almost every real bypass is one of those two.
  • Duplicate X-Forwarded-For header lines are handled differently by nginx, Go, Node and HAProxy, and code using Go's Header.Get reads only the attacker's line.
  • A trusted-proxy list is worthless without an origin lock: if the origin answers requests that did not pass the CDN, the trust boundary has a hole in it.

Client IP spoofing through proxies is the trivial attack of sending your own X-Forwarded-For header and having a server believe it. There is no protocol-level defence: the header is ordinary request metadata that any HTTP client can set, and a proxy that appends to it has no way to distinguish the entries it wrote from the entries a client typed. Safety comes from exactly one place, knowing which hops wrote which entries, and that knowledge comes from a trusted-proxy configuration backed by a network control.

bash
curl -H 'X-Forwarded-For: 127.0.0.1' https://app.example.com/admin

If that returns anything other than the same response as the request without the header, you have a finding.

What an attacker gets#

TargetMechanismTypical impact
Rate limitsRotate a fresh fake IP per requestUnlimited credential stuffing, OTP brute force, scraping
IP allowlistsClaim an office or partner addressAccess to staging, internal APIs, admin routes
Localhost checksClaim 127.0.0.1 or ::1Debug endpoints, metrics, actuator routes, "internal only" panels
Geo restrictionsClaim an address in a permitted countryLicensing, pricing and compliance bypass
Abuse controlsClaim a clean addressBlocklist and reputation evasion in front of a WAF
Log integrityWrite arbitrary text into the client IP fieldPoisoned dashboards, broken incident timelines, injection into log consumers
AvailabilityClaim an address you want punishedAutomated banning systems block a legitimate user, a partner, or a shared NAT

That last row is the one defenders forget. A system that reads a spoofable field and then blocks the address it finds is a remote-controlled denial of service. Feed it the address of your CDN's egress range, your own office, or a mobile carrier NAT, and it will take them offline for you.

Why the header cannot be trusted end to end#

X-Forwarded-For has no authentication, no integrity protection and no way to mark where the client-supplied portion ends. The same is true of Forwarded (RFC 7239 says so in its security considerations), X-Real-IP, True-Client-IP, CF-Connecting-IP and every other vendor variant. A proxy that appends produces a list like this:

text
X-Forwarded-For: 10.0.0.1, 198.51.100.7, 203.0.113.44
                 ^^^^^^^^  attacker typed this before sending
                           ^^^^^^^^^^^^  CDN appended (real client)
                                         ^^^^^^^^^^^^  your LB appended (the CDN)

Reading left to right gives the attacker's value. Reading right to left, discarding entries your own hops wrote, gives the truth. The whole discipline is contained in that sentence, and the mechanics per implementation are in configuring trusted proxies.

Vulnerable patterns#

PatternCode smellFix
Leftmost entryxff.split(',')[0].trim(), req.headers['x-forwarded-for'].split(',')[0]Walk from the right, discard trusted addresses, take the first survivor
Trust from any sourceapp.set('trust proxy', true), set_real_ip_from 0.0.0.0/0, forwardedHeaders.insecure: trueAn explicit CIDR list or hop count matching the real chain
Vendor header honoured unconditionallyReading CF-Connecting-IP or True-Client-IP without checking the peerAccept the header only when the socket peer is in the CDN's published ranges
CDN trusted, origin openA correct CIDR list plus an origin that answers on its public IPOrigin lock: firewall, mutual TLS, or a secret header
Recursive skip over a range attackers can occupyreal_ip_recursive on with set_real_ip_from 10.0.0.0/8 in a shared VPCTrust only the specific proxy addresses, not the whole private space
Hop count out of sync with topologyxff_num_trusted_hops: 2 after the CDN was removedReview the hop count in the same change that adds or removes a hop
Duplicate header linesr.Header.Get("X-Forwarded-For") in GoNormalise to one header at the edge, or read all values
Header used for authorisationif client_ip in ADMIN_IPS: allow()Authenticate the principal; use IP as a signal, never as the decision
Only the derived IP is loggedOne address field in the log formatLog the socket peer and the derived IP as separate fields

Taking the leftmost entry#

This is the most common single bug because it looks right. The client is on the left, so the leftmost value must be the client. It is the client's value in the sense that the client wrote it, which is precisely the problem. The leftmost entry is only correct when you can prove no client ever sends the header, and you cannot prove that.

Trusting the header from any source#

trust proxy: true in Express, set_real_ip_from 0.0.0.0/0 in nginx, forwardedHeaders.insecure: true in Traefik, and Spring Boot's framework strategy all say the same thing: apply the header regardless of who sent it. On a service reachable from the internet these are equivalent to letting the client choose req.ip.

Trusting a CDN without locking the origin#

Your set_real_ip_from lists the CDN's ranges and your rate limiter keys on the derived address. An attacker resolves the origin's IP (historical DNS, a certificate transparency log, an SPF record, a leaky error page, a subdomain that was never proxied) and connects directly:

bash
curl --resolve app.example.com:443:203.0.113.99 \
     -H 'X-Forwarded-For: 8.8.8.8' https://app.example.com/api/login

Whether this works depends on the fallback path. If the origin's config trusts only CDN ranges, the header is ignored and the attacker's real address is used, which is a correct fail-safe. If the origin instead uses hop counting, or if a permissive default matched, the attacker is now the client of their choice. Either way the origin is exposed to everything the CDN was supposed to filter.

real_ip_recursive misuse#

real_ip_recursive on is the correct setting for a multi-hop chain, but it means "keep skipping entries while they are trusted". If set_real_ip_from includes a range an attacker can obtain an address in, a shared VPC, a multi-tenant cluster, a VPN pool, then an attacker connecting from that range gets their own address skipped and nginx selects the entry they placed to the left of it. Recursive skipping amplifies an over-broad trust list instead of merely wasting it.

Hop-count mismatch#

Hop counting selects entry number N from the right without looking at any address. If the chain is later shortened, by removing a CDN, moving a service behind a different ingress, or serving a request through an internal path with fewer proxies, index N lands inside the attacker-controlled prefix. There is no error message. The value simply becomes wrong in the attacker's favour, which is why hop counting should be treated as configuration that is coupled to network topology.

Multiple headers: how the same request becomes two different IPs#

RFC 9110 allows a recipient to combine multiple field lines with the same name into one comma-separated value when the field is a list. Implementations vary in when they combine, and in what an application-level accessor returns. Send this:

http
GET /whoami HTTP/1.1
Host: app.example.com
X-Forwarded-For: 1.2.3.4
X-Forwarded-For: 5.6.7.8
StackWhat arrives at the applicationSelection gotcha
nginxBoth lines kept; $http_x_forwarded_for joins them with , ; realip inspects the joined listproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for collapses to one line, which is good hygiene
Apache httpdDuplicate field lines merged with , ; mod_remoteip sees the merged valueMerging is done before the module runs, so right-to-left walking is consistent
Node.js / ExpressThe parser joins duplicates with , into a single stringtrust proxy then operates on the joined list, so the risk is the trust setting, not the joining
Go net/httpr.Header is map[string][]string with two entriesHeader.Get() returns only the first line, so a handler using Get reads 1.2.3.4 and ignores everything a proxy appended to the second line
HAProxyBoth lines retainedhdr() splits on commas across all occurrences, fhdr() returns whole lines; hdr_ip(x-forwarded-for,-1) gets the last value overall
EnvoyValues coalesced into one comma-separated headerHop counting then indexes into the combined list

The Go row is the practical attack. A handler that calls r.Header.Get("X-Forwarded-For") and splits on commas is reading the first header line, and an attacker controls which line is first simply by sending theirs before the proxy adds another. Any proxy that appends by adding a new header line rather than rewriting the existing one puts its trustworthy data in position two, where Get never looks. The defence is to normalise at the edge: have the outermost proxy replace the header with a single canonical line.

Log poisoning#

Most servers log the header value as received, without validating that it parses as an IP address. X-Forwarded-For: $(whoami) or a value containing quotes, newlines or ANSI escape sequences ends up in your access log and then in whatever consumes it. nginx's access log escapes ", \ and characters outside the printable ASCII range as \xNN by default, so raw CRLF injection into the log file is blocked, but the string still flows into dashboards, alerting rules and any tool that parses the field.

Two consequences worth designing for. First, anything that reads an IP out of a log and acts on it (fail2ban, an automated blocklist, a WAF feedback loop) must read a field your infrastructure produced, not one the client supplied. Second, if you use JSON logging, confirm that your log format escapes the field correctly, because a value containing a quote in a hand-built JSON template produces malformed records that a strict parser will drop, silently losing the surrounding requests too.

Defence in depth#

  1. Lock the origin to the edge. Pick at least one: a firewall or security group restricted to the CDN's published ranges, mutual TLS between the CDN and the origin (see mutual TLS through a proxy), or a high-entropy secret header injected by the CDN and required by the origin. The secret header is the weakest of the three because it is replayable by anyone who obtains it, but it is better than nothing and it works when you do not control the network.
  2. Configure trust explicitly at the outermost proxy, once. Resolve the client IP there and pass one canonical value inward. Multiple layers each re-deriving from the raw header multiplies the number of configurations that must agree.
  3. Never authorise on IP alone. Where an IP allowlist is a compliance requirement, enforce it at the network layer, not by reading a header in application code.
  4. Log both addresses. The socket peer and the derived client IP, in separate fields, on every request:
nginx
log_format sec '$time_iso8601 peer=$realip_remote_addr client=$remote_addr '
               'xff="$http_x_forwarded_for" host="$host" status=$status';

When an incident starts, peer tells you whether the request came through the expected path and client tells you who it claims to be. Without both, you cannot distinguish a CDN-forwarded request from a direct one after the fact.

  1. Key rate limits on more than the address. Combine the derived IP with a session identifier, an API key, or a device signal, so that a spoofable value is not the only partition key.
  2. Strip on ingress. The outermost proxy should overwrite, not preserve, any client-supplied copy of the headers you rely on, including the vendor-specific ones. Traefik and Caddy do this by default for untrusted peers; nginx and HAProxy do not unless you tell them to.

Testing whether your own stack is spoofable#

Expose a temporary endpoint that reports both addresses, as described in configuring trusted proxies, then run this from an untrusted network:

bash
BASE=https://app.example.com/whoami

# 1. Control
curl -s $BASE

# 2. The classic
curl -s -H 'X-Forwarded-For: 1.2.3.4' $BASE

# 3. Loopback, the allowlist unlock
curl -s -H 'X-Forwarded-For: 127.0.0.1' $BASE

# 4. Duplicate header lines (tests Get-vs-Values bugs)
curl -s -H 'X-Forwarded-For: 1.2.3.4' -H 'X-Forwarded-For: 5.6.7.8' $BASE

# 5. Right-hand padding: does hop counting land where you think?
curl -s -H 'X-Forwarded-For: 1.2.3.4, 5.6.7.8, 9.9.9.9' $BASE

# 6. The other headers
curl -s -H 'X-Real-IP: 127.0.0.1' $BASE
curl -s -H 'Forwarded: for=127.0.0.1;proto=https' $BASE
curl -s -H 'True-Client-IP: 127.0.0.1' $BASE
curl -s -H 'CF-Connecting-IP: 127.0.0.1' $BASE
curl -s -H 'X-Client-IP: 127.0.0.1' $BASE
curl -s -H 'X-Originating-IP: 127.0.0.1' $BASE

# 7. Origin lock: bypass the CDN entirely
curl -sv --resolve app.example.com:443:<origin-ip> \
     -H 'X-Forwarded-For: 1.2.3.4' $BASE
TestPassFail
2, 3, 6Derived IP is still your real addressDerived IP is the forged value
4Same result as test 2A different result, indicating first-line or last-line selection differences
5Same result as test 2Derived IP is one of the padded values, indicating hop counting into attacker data
7Connection refused, reset, or a TLS failureAny HTTP response at all

Run tests 2 and 3 against every entry point, not just the main hostname. The usual finding is that www is correctly configured and an old api-legacy or staging name points straight at the origin. The client IP resolver is useful for reasoning about what a given header and trusted set should produce before you compare it with what your stack actually does.

Also test the PROXY protocol path if you use it. A PROXY-enabled listener reachable from outside is strictly worse than a spoofable XFF, because the forged address arrives at layer 4 and every downstream component, including the ones that correctly ignore headers, believes it.

The framework-defaults class of vulnerability#

There is a recurring class of advisories, rather than a single one, in which a web framework, middleware or rate-limiting library derives a client IP from X-Forwarded-For without a configured trust boundary. The variants repeat:

  • A library defaults to the leftmost entry so that "it works" behind any proxy.
  • A framework's documented deployment recipe enables proxy header handling globally without a source check, and applications copy it.
  • A rate limiter keys on a derived address that the trust configuration never actually validated.
  • An administrative interface restricted to 127.0.0.1 is reached through a proxy that forwards a client-controlled address into the check.

Two things follow for review work. First, treat any default that turns on forwarded-header handling without asking for a proxy list as a finding in itself, regardless of whether an advisory exists for that specific version. Second, when you audit a dependency, check the trust model rather than the changelog: does it require you to declare the boundary, and does it fail closed if you do not? The relevant weakness identifiers are CWE-348 (use of a less trusted source) and CWE-807. Add the header-handling review to the wider reverse proxy security checklist.

Failure modes#

A user reports being rate limited immediately on a fresh connection. Someone is spoofing that user's address into your limiter, or your limiter is keyed on a shared proxy address. Compare peer and client in the logs for the affected requests.

Automated blocking bans your own CDN range or office. A log-driven banning tool is reading a client-supplied field. Point it at the field your proxy writes and validate that the value parses as an address before acting on it.

Access logs contain non-IP text in the client field. Confirmation that the header is being logged unvalidated. Not exploitable by itself, but it proves the value reaching your logs is attacker controlled, so check everything downstream that consumes it.

Geo or abuse rules stop matching after an infrastructure change. A hop count that no longer matches the chain, or a CIDR list missing a newly added hop. The tell is that the derived IP becomes a stable infrastructure address (CIDR case) or an implausible client-supplied one (hop-count case).

Direct-to-origin requests appear in logs with a peer that is not the CDN. The origin lock is missing or has a hole. This is urgent even if the client IP handling is correct, because every edge-layer control is being bypassed at the same time.

Frequently asked questions#

Can X-Forwarded-For be spoofed?#

Yes, completely. It is a normal request header and any HTTP client can set it to any value, including several values. Only the entries appended by proxies you control are meaningful, which is why the header must be read from the right using a configured trusted-proxy list.

Is the Forwarded header from RFC 7239 harder to spoof than X-Forwarded-For?#

No. RFC 7239 states in its security considerations that the information in the header is not trustworthy and can be forged. Its advantages are a defined grammar and the ability to carry protocol, host and port in one field, not authenticity.

Does using a CDN protect me from client IP spoofing?#

Only if the origin cannot be reached directly. A CDN that overwrites forwarded headers gives you a reliable value on the CDN path, but an attacker who connects straight to the origin bypasses the CDN and whatever it enforced. Restrict the origin with a firewall, mutual TLS, or a required secret header.

Should I take the first or the last X-Forwarded-For entry?#

Neither, unconditionally. Walk from the last entry towards the first, discarding addresses that belong to proxies you trust, and take the first address that remains. Taking the last entry is correct only when exactly one trusted proxy appends to the header.

Is trusting 127.0.0.1 from X-Forwarded-For ever safe?#

No. A loopback address in a forwarded header means a client typed it, since a genuinely local request does not traverse a proxy that would add it. Any check of the form "if the client IP is localhost, allow" is an authentication bypass waiting to be found.

How do I stop clients from sending X-Forwarded-For at all?#

You cannot stop them sending it, but you can stop it mattering. Have the outermost proxy overwrite the headers your application reads rather than appending to them, and configure a trusted-proxy list so a client-supplied value is never selected even if it survives.

Why do two components in my stack disagree about the client IP?#

Usually because each derives it independently with different rules: one takes the leftmost entry, another walks from the right, and a third reads only the first of two duplicate header lines. Resolve the address once at the edge and pass a single canonical value inward.

Does HTTPS prevent header spoofing?#

No. TLS protects the header in transit from third parties; it does nothing about the client, which is the party writing the header in the first place. An attacker's own TLS connection carries an attacker's own headers.

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.

  1. RFC 7239 Forwarded HTTP Extension, Security Considerations
  2. RFC 9110 HTTP Semantics, field order and combining
  3. CWE-348 Use of Less Trusted Source
  4. CWE-807 Reliance on Untrusted Inputs in a Security Decision
  5. nginx ngx_http_realip_module
  6. nginx ngx_http_log_module (escape parameter)
  7. Express behind proxies
  8. Envoy HTTP header manipulation
  9. Cloudflare authenticated origin pulls

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.

More in client ip and forwarding headers#