---
title: "X-Real-IP and X-Forwarded-For: What Proxies Add, Tested"
url: https://proxy.wiki/research/proxy-header-leakage-measured/
type: Research Report
author: "proxy.wiki editorial"
published: 2026-10-21
updated: 2026-10-21
site: proxy.wiki
topics: ["Anti-bot systems"]
data_status: measured
sample: "365 requests: 37 cells of 5 in the main run (11 proxy configurations plus 2 curl baselines), and 180 follow-up requests in 4 checks"
method: https://proxy.wiki/research/proxy-header-leakage-measured/#method
key_figure: "0 of 11 proxy configurations added X-Real-IP, and all 11 passed a forged one through unchanged"
license: CC BY 4.0; quote freely with attribution to https://proxy.wiki/
---

# X-Real-IP and X-Forwarded-For: What Proxies Add, Tested

> We ran Squid, Apache, glider, Go's ReverseProxy and socat in front of a raw listener. None added X-Real-IP; four of nine defaults added X-Forwarded-For.

## Key takeaways

- 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.
- Four of the nine default configurations added X-Forwarded-For: Squid forward, Squid accel, Apache reverse and Go's NewSingleHostReverseProxy. None added Forwarded.
- Apache's forward proxy, with no authentication configured, delivered the client's Proxy-Authorization header to the destination in 5 of 5 plain-HTTP requests.
- Over HTTPS through CONNECT, and through SOCKS5, no proxy added any header: the header block matched a direct request.
- Squid with via off and forwarded_for delete still added Cache-Control: max-age=259200, a value that comes from its refresh_pattern default.

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.7` and `Forwarded: 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:pass` to 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 |

_Headers added to a plain HTTP GET, per configuration, 21 October 2026, 5 requests each_

| 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](/glossary/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.

_What 11 configurations did with a client-supplied header, 21 October 2026, 5 requests each_

| 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](/glossary/via-header/).

`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](/guides/403-vs-407-vs-429/) 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.

_Distinct header orders glider sent to the server in 50 requests per scenario, 21 October 2026_

| 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](/research/default-request-headers/), 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](/glossary/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](/glossary/http-connect/) or [SOCKS5](/glossary/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](/guides/how-to-use-a-proxy-with-curl/) lists the flags for every proxy type, and the [proxy environment variables guide](/guides/proxy-environment-variables/) 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](https://creativecommons.org/licenses/by/4.0/). Credit proxy.wiki and link to this page.

- [proxy-header-leakage-measured.csv](/wp-content/uploads/pw-data/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](/wp-content/uploads/pw-data/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](/glossary/x-forwarded-for/), and [what a proxy server is](/guides/what-is-a-proxy-server/) for how forward and reverse proxies differ.

## Data

- [proxy-header-leakage-measured.csv](https://proxy.wiki/wp-content/uploads/pw-data/proxy-header-leakage-measured.csv) (CSV, 19.4 KB, 37 rows)
- [proxy-header-leakage-measured.json](https://proxy.wiki/wp-content/uploads/pw-data/proxy-header-leakage-measured.json) (JSON, 229.3 KB)

Licensed CC BY 4.0. Cite <https://proxy.wiki/research/proxy-header-leakage-measured/>.

## Frequently asked questions

### What is the X-Real-IP header?

It is a request header carrying a single client address, set by a reverse proxy for the application behind it. It is a convention most closely tied to nginx, whose documentation shows proxy_set_header X-Real-IP $remote_addr as an example and whose realip module reads X-Real-IP by default. In our test of 11 proxy configurations, none added it on its own.

### What is the difference between X-Real-IP and X-Forwarded-For?

X-Forwarded-For is a comma-separated list that proxies can append to, so after several hops it holds several addresses. X-Real-IP holds one address that one proxy chose to set. In our test, four of nine default configurations added X-Forwarded-For and none added X-Real-IP. Neither header proves anything unless a proxy you control set it.

### Can I trust X-Real-IP on my server?

Only when a proxy you run set it and you accept it only from that proxy's address, which is what nginx's set_real_ip_from list is for. All 11 configurations we tested passed a client's forged X-Real-IP straight through, so any of them in the path lets the client choose the value.

### Does a proxy reveal my IP address in its headers?

It depends on the software and the protocol. Squid as a forward proxy at its defaults sent the client's address in X-Forwarded-For on every plain-HTTP request we made. Apache's forward proxy and glider did not. Over HTTPS through CONNECT, and over SOCKS5, none of the tested proxies added anything, because the request sits inside a tunnel the proxy does not read.

### How do I stop Squid from adding X-Forwarded-For and Via?

Set forwarded_for delete and via off. In our test that removed both headers, including an X-Forwarded-For the client had sent. It did not remove Cache-Control: max-age=259200, which comes from refresh_pattern, and client-supplied X-Real-IP and Forwarded headers still passed through unchanged.

### Why did X-Real-IP arrive as X-Real-Ip?

Go rewrites header names into its canonical form, and textproto.CanonicalMIMEHeaderKey("X-Real-IP") returns X-Real-Ip on Go 1.24.4. glider and Go's httputil reverse proxy both sent the renamed form. HTTP field names are case-insensitive, so the value still works, but the spelling suggests a Go program handled the request.

## Sources

1. [RFC 9110 section 7.6.3: Via](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.6.3)
2. [RFC 9110 section 7.6.1: Connection and hop-by-hop fields](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.6.1)
3. [RFC 9110 section 5.1: field names are case-insensitive](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1)
4. [RFC 9112 appendix C.2.2: Proxy-Connection](https://www.rfc-editor.org/rfc/rfc9112.html#appendix-C.2.2)
5. [RFC 7239: Forwarded HTTP Extension](https://www.rfc-editor.org/rfc/rfc7239.html)
6. [RFC 5737: IPv4 address blocks reserved for documentation](https://www.rfc-editor.org/rfc/rfc5737.html)
7. [nginx: proxy_set_header and its defaults](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header)
8. [nginx: ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html)
9. [Squid configuration: forwarded_for](http://www.squid-cache.org/Doc/config/forwarded_for/)
10. [Squid configuration: via](http://www.squid-cache.org/Doc/config/via/)
11. [Squid configuration: refresh_pattern](http://www.squid-cache.org/Doc/config/refresh_pattern/)
12. [Squid configuration: visible_hostname](http://www.squid-cache.org/Doc/config/visible_hostname/)
13. [Apache mod_proxy: ProxyVia, ProxyAddHeaders, ProxyPreserveHost](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html)
14. [Apache mod_proxy_http: Proxy-Chain-Auth](https://httpd.apache.org/docs/2.4/mod/mod_proxy_http.html)
15. [Apache mod_proxy_connect: AllowCONNECT](https://httpd.apache.org/docs/2.4/mod/mod_proxy_connect.html)
16. [Apache httpd source: proxy_util.c request header handling](https://github.com/apache/httpd/blob/2.4.x/modules/proxy/proxy_util.c)
17. [Go net/http/httputil: ReverseProxy, Rewrite and SetXForwarded](https://pkg.go.dev/net/http/httputil)
18. [Go net/http: Transport.DisableCompression](https://pkg.go.dev/net/http#Transport)
19. [Go net/textproto: CanonicalMIMEHeaderKey](https://pkg.go.dev/net/textproto#CanonicalMIMEHeaderKey)
20. [The Go Programming Language Specification: map iteration order](https://go.dev/ref/spec#For_range)
21. [glider source repository](https://github.com/nadoo/glider)
