Skip to content
Anti-bot systems

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.

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.

Measured by us 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 How it was measured Download the data CSV, JSON

On this page 11 sections
  1. Method
  2. What reached the server on a plain request
  3. X-Real-IP: added by none, forged values kept by all
  4. X-Forwarded-For: who adds it, and what happens to a forged one
  5. Via and Forwarded
  6. Proxy-Connection and Proxy-Authorization
  7. Headers nobody asked for
  8. HTTPS and SOCKS5 add nothing
  9. Reproduce it
  10. Limits
  11. 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.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 Drawn from the table below.
Headers added to a plain HTTP GET, per configuration, 21 October 2026, 5 requests each. Bar chart of Headers added by Configuration.11 rows, drawn from the table that follows this chart.squid-accelsquid-accel: 55squid-defaultsquid-default: 44apache-reverseapache-reverse: 44go-rewrite-xfgo-rewrite-xf: 44squid-anonsquid-anon: 22go-directorgo-director: 22apache-forwardapache-forward: 11glider-httpglider-http: 11go-rewritego-rewrite: 11glider-socks5glider-socks5: 00socatsocat: 00012345 headersHeaders added to a plain HTTP GET, per configuration, 21 October 2026, 5 requests each. Bar chart of Headers added by Configuration.11 rows, drawn from the table that follows this chart.squid-accelsquid-accel: 55squid-defaultsquid-default: 44apache-reverseapache-reverse: 44go-rewrite-xfgo-rewrite-xf: 44squid-anonsquid-anon: 22go-directorgo-director: 22apache-forwardapache-forward: 11glider-httpglider-http: 11go-rewritego-rewrite: 11glider-socks5glider-socks5: 00socatsocat: 000246 headers
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.

What 11 configurations did with a client-supplied header, 21 October 2026, 5 requests each Drawn from the table below.
What 11 configurations did with a client-supplied header, 21 October 2026, 5 requests each. Stacked bar chart of Passed unchanged, Appended to, Replaced, Removed by Client-supplied header.3 rows, drawn from the table that follows this chart.X-Forwarded-ForX-Forwarded-For · Passed unchanged: 44X-Forwarded-For · Appended to: 44X-Forwarded-For · Replaced: 11X-Forwarded-For · Removed: 22X-Real-IPX-Real-IP · Passed unchanged: 1111ForwardedForwarded · Passed unchanged: 99Forwarded · Removed: 22051015 configurationsWhat 11 configurations did with a client-supplied header, 21 October 2026, 5 requests each. Stacked bar chart of Passed unchanged, Appended to, Replaced, Removed by Client-supplied header.3 rows, drawn from the table that follows this chart.X-Forwarded-ForX-Forwarded-For · Passed unchanged: 44X-Forwarded-For · Appended to: 44X-Forwarded-For · Replaced: 1X-Forwarded-For · Removed: 22X-Real-IPX-Real-IP · Passed unchanged: 1111ForwardedForwarded · Passed unchanged: 99Forwarded · Removed: 22051015 configurations
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.

Distinct header orders glider sent to the server in 50 requests per scenario, 21 October 2026 Drawn from the table below.
Distinct header orders glider sent to the server in 50 requests per scenario, 21 October 2026. Bar chart of Distinct header orders in 50 requests by Scenario.3 rows, drawn from the table that follows this chart.02.557.5 ordersplain (4 headers): 44plain (4 headers)spoof (7 headers): 77spoof (7 headers)proxyauth (4 headers): 44proxyauth (4 headers)Distinct header orders glider sent to the server in 50 requests per scenario, 21 October 2026. Bar chart of Distinct header orders in 50 requests by Scenario.3 rows, drawn from the table that follows this chart.plain (4 headers)plain (4 headers): 44spoof (7 headers)spoof (7 headers): 77proxyauth (4 headers)proxyauth (4 headers): 4402.557.5 orders
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?
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
  2. RFC 9110 section 7.6.1: Connection and hop-by-hop fields
  3. RFC 9110 section 5.1: field names are case-insensitive
  4. RFC 9112 appendix C.2.2: Proxy-Connection
  5. RFC 7239: Forwarded HTTP Extension
  6. RFC 5737: IPv4 address blocks reserved for documentation
  7. nginx: proxy_set_header and its defaults
  8. nginx: ngx_http_realip_module
  9. Squid configuration: forwarded_for
  10. Squid configuration: via
  11. Squid configuration: refresh_pattern
  12. Squid configuration: visible_hostname
  13. Apache mod_proxy: ProxyVia, ProxyAddHeaders, ProxyPreserveHost
  14. Apache mod_proxy_http: Proxy-Chain-Auth
  15. Apache mod_proxy_connect: AllowCONNECT
  16. Apache httpd source: proxy_util.c request header handling
  17. Go net/http/httputil: ReverseProxy, Rewrite and SetXForwarded
  18. Go net/http: Transport.DisableCompression
  19. Go net/textproto: CanonicalMIMEHeaderKey
  20. The Go Programming Language Specification: map iteration order
  21. glider source repository

Read this page as Markdown · Quote it freely under CC BY 4.0 with a link back.