Trusting a proxy: five frameworks, five different contracts
Put a reverse proxy in front of an application and the same three facts go missing: who the client is, whether they used HTTPS, and which host they asked for. Every framework here has an answer. No two of them have the same answer, and one of them does not answer the first question at all.
Everything below except the Spring Boot row was reproduced in a single session with
identical inputs — the same X-Forwarded-For: 203.0.113.7, the same forged variant
1.2.3.4, 203.0.113.7, and the same socket address behind them. Spring Boot was read
at source; there is no JVM on the machine these were run on, and that row is marked.
They are not solving the same problem
Section titled “They are not solving the same problem”The first surprise is not a subtlety about parsing. It is that Django has no client-IP feature at all.
SECURE_PROXY_SSL_HEADER changes request.scheme and nothing else. REMOTE_ADDR
is never rewritten, no matter what X-Forwarded-For contains — confirmed across every
case tested. A Django application that wants the real client address has to extract it
itself or install something that does. The setting that looks like Django’s answer to
this problem answers half of it.
| Framework | What you turn on | Client IP | Scheme | Checks who sent the header |
|---|---|---|---|---|
| Django 6.1 | SECURE_PROXY_SSL_HEADER |
never | yes | no |
| Flask / Werkzeug | ProxyFix(x_for=…, x_proto=…) |
yes | yes | no |
| Express | trust proxy |
yes | yes | only in the address form |
| FastAPI / uvicorn | --proxy-headers + forwarded_allow_ips |
yes | yes | yes, by default |
| Spring Boot | server.forward-headers-strategy |
yes | yes | yes, via internal-proxies |
Two of them check the sender; the others just believe the header
Section titled “Two of them check the sender; the others just believe the header”This is the axis that matters most and the one least often discussed.
ProxyFix and Django’s setting are pure header transformations. They ask nothing about
where the request came from — if the header is present, it is used. Their safety comes
entirely from the network, and neither can tell you whether that network exists.
uvicorn and Tomcat do something categorically different: they compare the peer
address against an allow-list before honouring anything. Measured on uvicorn 0.52.3,
where forwarded_allow_ips defaults to 127.0.0.1 — read at
uvicorn/config.py as os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1"):
forwarded_allow_ips |
peer allowed? | forged X-Forwarded-For |
resulting client |
|---|---|---|---|
127.0.0.1 (default) |
yes | 1.2.3.4, 203.0.113.7 |
203.0.113.7 |
10.9.9.9 |
no | 1.2.3.4, 203.0.113.7 |
127.0.0.1 — headers ignored entirely |
* |
everything | 1.2.3.4, 203.0.113.7 |
1.2.3.4 — the forged value |
uvicorn’s default is the only one of the five that is both switched on and
forgery-resistant. It is also the one most likely to be wrong in practice, because
127.0.0.1 assumes the proxy is on loopback, and in a container it is not — which lands
you in the middle row, where everything is ignored and nothing is broken enough to
notice. The documented escape hatch is '*', which is the bottom row.
Express supports both models, and the safer one is the less popular one. The hop count everybody uses performs no sender check; the address form does:
trust proxy |
forged header | req.ip |
|---|---|---|
1 |
1.2.3.4, 203.0.113.7 |
203.0.113.7 |
2 |
1.2.3.4, 203.0.113.7 |
1.2.3.4 |
'127.0.0.1' (peer is loopback) |
1.2.3.4, 203.0.113.7 |
203.0.113.7 |
'10.9.9.9' (peer is not) |
1.2.3.4, 203.0.113.7 |
::ffff:127.0.0.1 — ignored |
Given a fixed proxy address, the address form is strictly better than a hop count: it cannot be off by one, and it fails closed when traffic arrives from somewhere unexpected. It behaves exactly like uvicorn’s default.
They read the header from opposite ends
Section titled “They read the header from opposite ends”Django and Flask disagree about which end of a comma-separated header is authoritative. Same header, same deployment, opposite answers:
X-Forwarded-Proto received |
Django request.scheme |
Flask request.scheme |
|---|---|---|
http,https |
http — leftmost |
https — rightmost |
https,http |
https — leftmost |
http — rightmost |
Neither is a bug. Django is asking what the client’s own connection was, at the far end of the chain. Werkzeug is asking what the nearest proxy said, at the near end. They are different questions with the same header name, and a value copied from one project’s configuration into another means the opposite of what it did.
Express and uvicorn take a third position: walk in from the right and stop at the first
address you do not trust. That is why trust proxy: '*'-equivalent settings collapse to
the leftmost value — with everything trusted, there is no stopping point.
The same mistake is loud in one and silent in another
Section titled “The same mistake is loud in one and silent in another”Every framework here can be told to trust one hop too many. What differs is whether you find out.
| Framework | Misconfiguration | What you actually see |
|---|---|---|
| Django | wrong header name, or wrong value | every request looks plaintext → redirect loop, immediately |
| Flask | x_for one too high |
no second value exists, so it falls back to the socket → your logs fill with the proxy address |
| Express | trust proxy one too high |
nothing — the correct address is returned until somebody forges one |
| FastAPI / uvicorn | peer not in forwarded_allow_ips |
headers ignored → client is the proxy, scheme is http |
| Spring Boot | deployed somewhere the platform is not detected | strategy defaults to NONE → headers ignored, with no config difference to explain it |
Four of the five announce themselves in ordinary traffic. Express is the one that does not — its over-counted hop returns the right answer to every normal request and the wrong one to a crafted request, which is the definition of a control you cannot test by using the application.
That is worth knowing when deciding where to spend a review. In Flask, Django and uvicorn the wrong setting shows up as something visibly not working. In Express it shows up as an incident.
What to take from this
Section titled “What to take from this”- Find out whether your framework even offers what you think it does. If you are on Django and reading a client IP from somewhere, something other than Django is producing it.
- Prefer an address allow-list to a hop count where the framework offers one —
Express’s address form and uvicorn’s
forwarded_allow_ipscannot be off by one and fail closed. - Test with a forged header, not a browser. In at least one of these frameworks a normal request returns the correct answer regardless of the setting.
- None of this is worth anything if the application is reachable without the proxy. Every framework here trusts the network to make the header true, and none of them can check that.
Related
Section titled “Related”- Django SSL redirect behind a proxy — the scheme half, and the Cloudflare Flexible-mode trap
- Flask proxy headers —
ProxyFixand its counting direction - Express trust proxy — the two wrong values
- Express behind Cloudflare — where the hop count is not what people assume
- FastAPI proxy headers — uvicorn’s allow-list in its own cluster
- Spring Boot forwarded headers — the environment-dependent default