Skip to content

Trusting a proxy: five frameworks, five different contracts

Severity: mediumApplies to: Django 6.1Applies to: Flask 3.1.3 / Werkzeug 3.1.8Applies to: Express 5.2.1Applies to: FastAPI 0.141.1 / uvicorn 0.52.3Applies to: Spring Boot 4.1.0Facts last verified 2026-08-16 against Django 6.1 · Werkzeug 3.1.8 · Express 5.2.1 · uvicorn 0.52.3 · Spring Boot 4.1.0 (read at source, no JVM)

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.

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.

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 NONEheaders 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.

  • 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_ips cannot 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.