The finding
0 of 11proxy configurations added X-Real-IP, and all 11 passed a forged one through unchanged
None of the 11 proxy configurations added X-Real-IP, and all 11 passed a forged X-Real-IP from the client through with its value unchanged.
On this page 11 sections
- Method
- What reached the server on a plain request
- X-Real-IP: added by none, forged values kept by all
- X-Forwarded-For: who adds it, and what happens to a forged one
- Via and Forwarded
- Proxy-Connection and Proxy-Authorization
- Headers nobody asked for
- HTTPS and SOCKS5 add nothing
- Reproduce it
- Limits
- Download the data
No proxy we tested added an X-Real-IP header. We ran 11 configurations built from Squid, Apache httpd, glider, Go’s net/http/httputil and socat, and 0 of 11 added it. All 11 passed a forged X-Real-IP from the client through to the server with the value untouched. X-Real-IP appears where someone has configured it, most often with nginx’s proxy_set_header X-Real-IP $remote_addr. A backend that reads it without checking which machine sent it is reading a value the client picked.
X-Forwarded-For is the header proxies add on their own. Four of the nine configurations left at their defaults added it, two added Via, and none added the standard Forwarded header. Over HTTPS through a CONNECT tunnel, and through SOCKS5, nothing was added at all. One result we did not expect: Apache’s forward proxy handed the client’s proxy credentials to the destination server.
Collected 21 October 2026 on one Mac (macOS 26.6.2, Apple silicon), over the loopback interface. 365 requests in total: 185 in the main run (37 cells of 5 requests) and 180 in four follow-up checks. Every request reached the listener and every one returned 200. The raw data is linked at the end under CC BY 4.0.
Method
A raw TCP listener on 127.0.0.1:28081 (plain HTTP) and 127.0.0.1:28444 (TLS with a self-signed certificate) read each connection up to the blank line that ends the header block, stored those bytes as received and answered with a small 200 carrying Cache-Control: no-store. Nothing sat between the last proxy and the listener. What it recorded is what the proxy put on the wire.
curl 8.7.1 made every request, each with a unique path so no proxy could answer from its cache. Each cell, meaning one configuration in one scenario, ran 5 times. There were four scenarios:
- plain: a GET with curl’s default headers and nothing else.
- spoof: the same GET with three forged headers set by the client,
X-Forwarded-For: 203.0.113.7,X-Real-IP: 203.0.113.7andForwarded: for=203.0.113.7. The address is from TEST-NET-3, a range RFC 5737 reserves for documentation. - proxyauth: forward proxies only. curl sent
--proxy-user user:passto a proxy that had no authentication configured. - https: forward and SOCKS5 proxies only. The same GET to the TLS listener, so the proxy carries an encrypted tunnel it cannot read.
To say what a proxy changed you need a record of what it was given. For forward proxies we pointed curl at the listener as if the listener were the proxy, which captures exactly what curl sends to any HTTP proxy; that capture is the reference. For reverse proxies, SOCKS5 and the HTTPS scenario the reference is the direct request. On reverse proxies the reference Host is the proxy’s own address, because that is what curl sends to them.
| Software | Version run | Configurations | What we set |
|---|---|---|---|
| Squid | 6.13 (Homebrew) | forward; forward with via off and forwarded_for delete; reverse (accel mode) |
Port, log and PID paths, visible_hostname |
| Apache httpd | 2.4.67 (the macOS system build) | forward (ProxyRequests On); reverse (ProxyPass /) |
Minimal module list, ServerName, AllowCONNECT for the test port |
| glider | commit d57d35c (v0.16.2 plus 3 commits), built from source with Go 1.24.4 | HTTP proxy; SOCKS5 | Listen addresses only |
Go net/http/httputil |
Go 1.24.4 | NewSingleHostReverseProxy; ReverseProxy with a Rewrite hook; Rewrite plus SetXForwarded |
The short program printed below |
| socat | 1.8.0.3 | TCP relay, used as a control | Listen and target addresses |
Squid’s visible_hostname and Apache’s ServerName were set to proxy-under-test so the test machine’s name stays out of the published data. Left unset, Squid uses the machine’s hostname, and a follow-up check below confirms it appears in Via. Nine of the 11 configurations set no header option at all; on this page they are “the defaults”. The other two, squid-anon and go-rewrite-xf, exist to show what the header options do. Apache’s AllowCONNECT defaults to ports 443 and 563, so the forward configuration also allows 28444; that directive decides which ports CONNECT may reach and nothing else.
What reached the server on a plain request
“No” below means the header was absent at the server. The address is 127.0.0.1 because client, proxy and server shared one machine. On a real network it would be the client’s own address.
| Configuration | Mode | Via | X-Forwarded-For | Forwarded | X-Real-IP | Other headers added |
|---|---|---|---|---|---|---|
| squid-default | forward | 1.1 proxy-under-test (squid/6.13) |
127.0.0.1 |
No | No | Cache-Control: max-age=259200, Connection: keep-alive |
| squid-anon | forward | No | No | No | No | Cache-Control: max-age=259200, Connection: keep-alive |
| squid-accel | reverse | 1.1 proxy-under-test (squid/6.13) |
127.0.0.1 |
No | No | Surrogate-Capability: proxy-under-test="Surrogate/1.0", Cache-Control, Connection |
| apache-forward | forward | No | No | No | No | Connection: close |
| apache-reverse | reverse | No | 127.0.0.1 |
No | No | X-Forwarded-Host: 127.0.0.1:23132, X-Forwarded-Server: proxy-under-test, Connection: Keep-Alive |
| glider-http | forward | No | No | No | No | Connection: close |
| glider-socks5 | SOCKS5 | No | No | No | No | None |
| go-director | reverse | No | 127.0.0.1 |
No | No | Accept-Encoding: gzip |
| go-rewrite | reverse | No | No | No | No | Accept-Encoding: gzip |
| go-rewrite-xf | reverse | No | 127.0.0.1 |
No | No | X-Forwarded-Host: 127.0.0.1:23137, X-Forwarded-Proto: http, Accept-Encoding: gzip |
| socat | TCP relay | No | No | No | No | None |
| Configuration | Headers added |
|---|---|
| squid-accel | 5 |
| squid-default | 4 |
| apache-reverse | 4 |
| go-rewrite-xf | 4 |
| squid-anon | 2 |
| go-director | 2 |
| apache-forward | 1 |
| glider-http | 1 |
| go-rewrite | 1 |
| glider-socks5 | 0 |
| socat | 0 |
The count includes hop-by-hop headers such as Connection, because they still reach the server and still differ between programs. Within every cell except one, all 5 requests produced the same header block apart from the path. The exception is glider, covered further down.
X-Real-IP: added by none, forged values kept by all
Every configuration delivered the client’s 203.0.113.7 unchanged. Four of them respelled the name on the way: glider’s HTTP proxy and all three Go configurations sent X-Real-Ip. Field names are case-insensitive under RFC 9110 section 5.1, so the value still counts, but the spelling is a small tell. Both programs are written in Go, and Go’s textproto.CanonicalMIMEHeaderKey("X-Real-IP") returns X-Real-Ip; we ran that line on Go 1.24.4 to check.
nginx is where the header usually comes from. Its proxy module redefines only Host and Connection by default, and its documentation shows proxy_set_header X-Real-IP $remote_addr as an example. The realip module, which is not built unless nginx is compiled with --with-http_realip_module, reads X-Real-IP by default and only accepts it from addresses listed in set_real_ip_from. That trust list is the part that matters. We did not run nginx in this study, so those statements come from its documentation, not from our capture.
Taken together: a value in X-Real-IP is only as good as the last hop you control. If that hop overwrites the header with the address it saw, and the application accepts the header only from that hop, it is useful. If any link in the chain is one of the 11 configurations above, a client can put any address it likes there and the server will receive it.
X-Forwarded-For: who adds it, and what happens to a forged one
Four of the nine default configurations added X-Forwarded-For: Squid as a forward proxy, Squid in accel mode, Apache as a reverse proxy and Go’s NewSingleHostReverseProxy. For a scraper the forward case is the one that bites. A Squid left at forwarded_for on, its documented default, tells every plain-HTTP site the address of the machine that sent the request. An X-Forwarded-For you did not send is one of the signs our transparent proxy entry lists for spotting a proxy in the path.
Apache adds its X-Forwarded-* trio only in reverse mode. The forward proxy added none. Its source code explains the choice: in a forward proxy, the comment says, these headers are “a potential privacy violation”, and it points to Via instead.
| Client-supplied header | Passed unchanged | Appended to | Replaced | Removed |
|---|---|---|---|---|
| X-Forwarded-For | 4 | 4 | 1 | 2 |
| X-Real-IP | 11 | 0 | 0 | 0 |
| Forwarded | 9 | 0 | 0 | 2 |
Appending is what Squid (both modes), Apache’s reverse proxy and the Go Director did: the server saw X-Forwarded-For: 203.0.113.7, 127.0.0.1. Apache’s forward proxy, glider in both modes and socat passed the forged value as it was. Two removed it. squid-anon did, because forwarded_for delete drops the whole header, and so did Go’s Rewrite hook, whose documentation says the inbound Forwarded and X-Forwarded-* headers are stripped before the hook runs. The one replacement is go-rewrite-xf. SetXForwarded appends to an existing X-Forwarded-For, but Rewrite has already removed the inbound one, so the server saw only 127.0.0.1.
Look at the X-Real-IP row again. Squid’s two directives for hiding itself, via off and forwarded_for delete, deal with Via and X-Forwarded-For. They leave X-Real-IP and Forwarded alone, and squid-anon passed both forged values to the server.
Via and Forwarded
Only Squid sent Via, in both of its default modes. RFC 9110 section 7.6.3 says a proxy MUST send an appropriate Via header in each message it forwards. Of the three HTTP forward proxies tested, Squid is the only one that did. Apache’s ProxyVia defaults to Off, which its documentation says passes an existing Via through unchanged and adds none. glider adds none.
Squid’s Via is informative: 1.1 proxy-under-test (squid/6.13) carries the software name, its exact version and the proxy’s hostname. In follow-up check C we removed visible_hostname from the config and sent 5 more requests. In 5 of 5, Via carried the test machine’s own hostname. We stored only a yes or no for that match, never the name itself. A default Squid therefore tells every plain-HTTP site which server it runs on and which release it is. RFC 9110 lets a proxy put a pseudonym there instead. More on the header in the Via glossary entry.
Forwarded, standardised in RFC 7239 in June 2014 as one header for what the X-Forwarded family carries, was added by none of the 11 configurations. Two removed a forged one, both of them Go Rewrite configurations. RFC 7239 section 8.1 is blunt that the header cannot be relied upon to be correct, and the X-Real-IP row above shows the same thing about its unofficial cousin.
Proxy-Connection and Proxy-Authorization
curl 8.7.1 sends Proxy-Connection: Keep-Alive to every HTTP proxy. RFC 9112 appendix C.2.2 encourages clients not to send it, so the header is a curl habit rather than a requirement. All four forward configurations removed it, as RFC 9110 section 7.6.1 says intermediaries should. It never reached the server.
Proxy credentials were a different story.
| Configuration | Proxy-Connection (curl sent Keep-Alive) | Proxy-Authorization, plain HTTP | Proxy-Authorization, HTTPS via CONNECT | Headers added over HTTPS |
|---|---|---|---|---|
| squid-default | Removed | Removed, 5 of 5 | Not at server, 5 of 5 | 0 |
| squid-anon | Removed | Removed, 5 of 5 | Not at server, 5 of 5 | 0 |
| apache-forward | Removed | Delivered to the server, 5 of 5 | Not at server, 5 of 5 | 0 |
| glider-http | Removed | Removed, 5 of 5 | Not at server, 5 of 5 | 0 |
| glider-socks5 | — | — | — | 0 |
A dash means the scenario does not apply to SOCKS5 and was not run. Apache’s forward proxy delivered Proxy-Authorization: Basic dXNlcjpwYXNz to the destination in all 5 plain-HTTP requests. That string is base64 for user:pass, which anyone can decode. The behaviour is deliberate. In proxy_util.c Apache removes the header only after it has authenticated the user itself, and keeps it even then when the Proxy-Chain-Auth variable is set. A proxy that never checked the credentials passes them on, and the comment next to that code says as much.
If a client holds credentials for one proxy and sends them to an Apache forward proxy that does not use them, every plain-HTTP site it visits receives them. That can happen in a chain, or when a scraper’s proxy list mixes authenticated and open endpoints. Over HTTPS the credentials stay on the CONNECT request: in follow-up check D, 20 requests through the four forward configurations, none reached the server inside the tunnel. The 407 guide covers how proxy authentication is negotiated in the first place.
Headers nobody asked for
Every Squid configuration added Cache-Control: max-age=259200, including squid-anon. 259,200 seconds is 4,320 minutes, the max value in Squid’s suggested refresh_pattern . 0 20% 4320 line, and Squid’s documentation says its default patterns apply when the config sets none, as ours did not. Follow-up check B tested the link: with refresh_pattern . 0 20% 60 added, the header read max-age=3600 in 5 of 5 requests. The number tracks the refresh pattern, and it survives via off and forwarded_for delete. A server that sees exactly 259200 can make a good guess about what forwarded the request.
In accel mode Squid also sent Surrogate-Capability: proxy-under-test="Surrogate/1.0", which names the proxy a second time.
Connection handling differed by program and even by mode. Squid sent Connection: keep-alive, Apache’s forward proxy and glider sent Connection: close, and Apache’s reverse proxy sent Connection: Keep-Alive with a capital K. curl itself sent none on a direct request.
The three Go configurations added Accept-Encoding: gzip. curl sent no Accept-Encoding, and Go’s HTTP transport requests gzip whenever a request carries none, unless DisableCompression is set. The Director version also kept the client’s Host: 127.0.0.1:23135 rather than the backend’s address. Go’s documentation says NewSingleHostReverseProxy does not rewrite Host, while SetURL does, and the two Rewrite configurations sent the backend’s Host as described.
Order is the last difference. glider writes headers by ranging over a Go map, and the Go specification says map iteration order is not specified and can change between iterations. In the main run its plain and spoof cells happened to show one order each across 5 requests, so follow-up check A sent 50 requests per scenario.
| Scenario | Distinct header orders in 50 requests | Requests in the most common order |
|---|---|---|
| plain (4 headers) | 4 | 35 |
| spoof (7 headers) | 7 | 11 |
| proxyauth (4 headers) | 4 | 32 |
Within each scenario, every order we saw was a rotation of one fixed sequence. In the spoof scenario all 7 possible rotations of its 7 headers turned up. So the set of orders is small, but no single order holds. Every other configuration kept one order across all of its requests. Header order is one of the signals covered in our study of default request headers by HTTP library, and a proxy that reorders headers changes that signal for every client behind it.
Our glider build is a 2022 commit, the newest in the local clone. On 21 October 2026 the three files that handle HTTP proxy headers on glider’s main branch differed from it only in request-line parsing, so we expect current builds to behave the same. We did not run one.
HTTPS and SOCKS5 add nothing
All four forward configurations carried the HTTPS requests through a CONNECT tunnel, and the 20 header blocks that reached the TLS listener were identical to a direct HTTPS request: Host, User-Agent, Accept, in that order. glider’s SOCKS5 listener matched the direct request in all three of its scenarios, 15 requests, and so did socat in 10. A forward proxy cannot edit bytes it cannot decrypt. Only a proxy that terminates TLS itself, which is TLS interception, could add a header to an HTTPS request, and we tested none.
For anyone sending traffic through a proxy this splits the question in two. On HTTPS through CONNECT or SOCKS5, none of the headers above can come from the proxy, so any that arrive were sent by your own client. On plain HTTP they depend entirely on which software sits in the path and how it is set up.
Reproduce it
The listener is plain Python 3, tested on 3.15.0 with no dependencies. It prints each header block as it arrives:
#!/usr/bin/env python3
# listener.py: print every request header block exactly as it arrives.
# Python 3.15.0, stdlib only. Usage: python3 listener.py [port]
import socket
import sys
import threading
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 28081
REPLY = (b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n"
b"Cache-Control: no-store\r\nConnection: close\r\n\r\nok\n")
def handle(conn, addr):
conn.settimeout(5)
buf = b""
try:
while b"\r\n\r\n" not in buf and len(buf) < 65536:
chunk = conn.recv(4096)
if not chunk:
break
buf += chunk
head = buf.split(b"\r\n\r\n", 1)[0].decode("latin-1")
if not head: # a port probe, not a request
return
print(f"--- from {addr[0]}:{addr[1]}\n{head}\n", flush=True)
conn.sendall(REPLY)
except OSError:
pass
finally:
conn.close()
srv = socket.create_server(("127.0.0.1", PORT), reuse_port=False)
print(f"listening on 127.0.0.1:{PORT}", flush=True)
while True:
c, a = srv.accept()
threading.Thread(target=handle, args=(c, a), daemon=True).start()
The Squid configuration for the squid-default row. Run it with squid -N -f squid.conf; every header directive is left at its default:
# squid.conf; create /tmp/pw (or change the four paths) before starting
http_port 127.0.0.1:23128
http_access allow all
visible_hostname proxy-under-test
pid_filename /tmp/pw/squid.pid
cache_log /tmp/pw/cache.log
access_log stdio:/tmp/pw/access.log
coredump_dir /tmp/pw
netdb_filename none
shutdown_lifetime 0 seconds
Then send the three plain-HTTP scenarios through it, and point curl at the listener itself to see what it gives a proxy:
# what curl sends to any HTTP proxy
curl -s -x http://127.0.0.1:28081 http://127.0.0.1:28081/as-proxy
# plain, spoof and proxyauth through the proxy under test
curl -s -x http://127.0.0.1:23128 http://127.0.0.1:28081/plain
curl -s -x http://127.0.0.1:23128 \
-H 'X-Forwarded-For: 203.0.113.7' -H 'X-Real-IP: 203.0.113.7' \
-H 'Forwarded: for=203.0.113.7' http://127.0.0.1:28081/spoof
curl -s -x http://127.0.0.1:23128 --proxy-user user:pass http://127.0.0.1:28081/proxyauth
Run through Squid 6.13, the second request printed this on the listener:
GET /spoof HTTP/1.1
Host: 127.0.0.1:28081
User-Agent: curl/8.7.1
Accept: */*
X-Real-IP: 203.0.113.7
Forwarded: for=203.0.113.7
Via: 1.1 proxy-under-test (squid/6.13)
X-Forwarded-For: 203.0.113.7, 127.0.0.1
Cache-Control: max-age=259200
Connection: keep-alive
The Apache forward proxy needs mod_proxy, mod_proxy_http and mod_proxy_connect with ProxyRequests On and a <Proxy "*"> block granting access. The Go rows come from this program, built with go build -o rp rp.go on Go 1.24.4:
package main
import (
"flag"
"log"
"net/http"
"net/http/httputil"
"net/url"
)
func main() {
mode := flag.String("mode", "director", "director | rewrite | rewrite-xf")
listen := flag.String("listen", "127.0.0.1:23136", "listen address")
target := flag.String("target", "http://127.0.0.1:28081", "backend URL")
flag.Parse()
u, err := url.Parse(*target)
if err != nil {
log.Fatal(err)
}
var h http.Handler
switch *mode {
case "director":
h = httputil.NewSingleHostReverseProxy(u)
case "rewrite":
h = &httputil.ReverseProxy{Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(u)
}}
case "rewrite-xf":
h = &httputil.ReverseProxy{Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(u)
r.SetXForwarded()
}}
default:
log.Fatalf("unknown mode %q", *mode)
}
log.Fatal(http.ListenAndServe(*listen, h))
}
To test a proxy you rent rather than run, put the listener on a machine with a public address and send requests to it through the proxy. The curl proxy guide lists the flags for every proxy type, and the proxy environment variables guide covers clients that read their proxy from the environment.
Limits
- One machine, loopback only. Every address is
127.0.0.1. On a real network X-Forwarded-For would carry the client’s address instead. - Specific versions. Squid 6.13 is older than the 7.x release Homebrew now offers, Apache is Apple’s 2.4.67 build, glider is a 2022 commit and Go is 1.24.4. Each row describes that version only.
- Not tested. nginx, HAProxy, tinyproxy, Privoxy, mitmproxy, Envoy, Traefik, Caddy and commercial proxy networks. They were not installed on the test machine and this run installed nothing new. What this page says about nginx comes from its documentation.
- “Default” means a minimal config. Each program started from a small file with every header directive unset. The config file a distribution ships can differ, for example by adding access rules, and we did not test those files.
- One client. curl 8.7.1 decides what reaches the proxy. A browser or another library sends a different set, and a proxy can only add to, pass or drop what it receives.
- HTTP/1.1 to the server only. No HTTP/2 or HTTP/3 hop was tested.
- Request headers only. We did not analyse what the proxies added to responses.
- Small samples per cell. 5 requests per cell, 50 per scenario for glider’s ordering. The glider result shows that 5 can miss variation, so treat “one order in 5” elsewhere as an observation, not a guarantee.
Download the data
Both files are released under CC BY 4.0. Credit proxy.wiki and link to this page.
- proxy-header-leakage-measured.csv: one row per cell (37 rows). For each tracked header it gives the value at the server and a status: added, passed, appended, replaced, removed or absent.
- proxy-header-leakage-measured.json: the same cells, every one of the 185 main-run requests with its full header block and curl command, the four follow-up checks, tool versions and the list of config changes from defaults.
For the header itself rather than this test, see the X-Forwarded-For entry, and what a proxy server is for how forward and reverse proxies differ.
Download the data
The raw results behind this study, as files. Reuse them under CC BY 4.0 with a link to this page.
Frequently asked questions
What is the X-Real-IP header?
What is the difference between X-Real-IP and X-Forwarded-For?
Can I trust X-Real-IP on my server?
Does a proxy reveal my IP address in its headers?
How do I stop Squid from adding X-Forwarded-For and Via?
Why did X-Real-IP arrive as X-Real-Ip?
Sources
- RFC 9110 section 7.6.3: Via
- RFC 9110 section 7.6.1: Connection and hop-by-hop fields
- RFC 9110 section 5.1: field names are case-insensitive
- RFC 9112 appendix C.2.2: Proxy-Connection
- RFC 7239: Forwarded HTTP Extension
- RFC 5737: IPv4 address blocks reserved for documentation
- nginx: proxy_set_header and its defaults
- nginx: ngx_http_realip_module
- Squid configuration: forwarded_for
- Squid configuration: via
- Squid configuration: refresh_pattern
- Squid configuration: visible_hostname
- Apache mod_proxy: ProxyVia, ProxyAddHeaders, ProxyPreserveHost
- Apache mod_proxy_http: Proxy-Chain-Auth
- Apache mod_proxy_connect: AllowCONNECT
- Apache httpd source: proxy_util.c request header handling
- Go net/http/httputil: ReverseProxy, Rewrite and SetXForwarded
- Go net/http: Transport.DisableCompression
- Go net/textproto: CanonicalMIMEHeaderKey
- The Go Programming Language Specification: map iteration order
- glider source repository