Proxy headers
request.client.host is either something the connection told you or something the
caller wrote in a header. Which one depends on two uvicorn settings, and the
combination that most deployments end up with is the permissive one.
The two settings
Section titled “The two settings”Read from uvicorn’s own configuration:
| Setting | Default |
|---|---|
proxy_headers |
True |
forwarded_allow_ips |
$FORWARDED_ALLOW_IPS, else '127.0.0.1' |
So X-Forwarded-For and X-Forwarded-Proto are honoured by default — but only
when the connection arrives from 127.0.0.1. That default is a sensible one for a
proxy running on the same host as the application.
It is the wrong shape for a container. In a compose stack or a Kubernetes pod the proxy
connects from another address entirely, so the forwarded headers are ignored,
request.client.host becomes the proxy’s IP, and request.url.scheme reads http
even though the browser used HTTPS.
Everything downstream inherits that: rate limits bucket every visitor together, IP
allow-lists match the proxy, audit logs record one address forever, and any redirect
built from the request URL points at http://.
Which is why the documented escape hatch is the problem
Section titled “Which is why the documented escape hatch is the problem”uvicorn’s help text is explicit about it:
Comma separated list of IP Addresses, IP Networks, or literals (e.g. UNIX Socket path) to trust with proxy headers. Defaults to the
$FORWARDED_ALLOW_IPSenvironment variable if available, or'127.0.0.1'. The literal'*'means trust everything.
'*' fixes every symptom above in one character, and it is what most search results
suggest. In uvicorn’s implementation it sets always_trust, which skips the address
check entirely — so X-Forwarded-For is accepted from any source.
If your application is reachable only through the proxy, that is survivable. If
anything can reach the application port directly — another pod, a misconfigured
service, a host port published for debugging — then any client can set
X-Forwarded-For to whatever it likes and your application will believe it.
proxy_headersoptionWhether uvicorn honours X-Forwarded-For and X-Forwarded-Proto at all. On by default, so the interesting question is never whether the headers are read — it is which senders are trusted, which is the next setting.
- accepts
True|False- default
True- set in
uvicorn.run(...) / --proxy-headers
forwarded_allow_ipsoptionWhich peer addresses may set the forwarded headers. The signature default is None, but it resolves at runtime to the FORWARDED_ALLOW_IPS environment variable and only then to 127.0.0.1 — so the effective default is loopback. In a container the proxy is not on loopback, which is why the documented escape hatch is "*"; that sets always_trust and skips the check entirely, making the header attacker-supplied.
- accepts
"127.0.0.1"|"10.0.0.5"|"*" (trust everything)- default
"127.0.0.1"- set in
uvicorn.run(...) / --forwarded-allow-ips
FORWARDED_ALLOW_IPSenvSets forwarded_allow_ips when the argument is not passed. Worth checking separately: an image or orchestrator can widen the trust boundary here without any change to the code that appears to configure it.
- accepts
any address list|"*"- set in
process environment
# name the proxy's network, do not use '*'uvicorn app.main:app \ --host 0.0.0.0 --port 8000 \ --proxy-headers \ --forwarded-allow-ips='10.0.0.0/8'forwarded_allow_ips accepts networks in CIDR form, so the proxy’s subnet is usually
the right unit — specific enough to reject arbitrary clients, broad enough to survive
the proxy being rescheduled onto a different address.
If you genuinely cannot enumerate the proxy’s address, the safer position is
--no-proxy-headers plus accepting that you have no real client IP, rather than '*'
plus believing a forged one.
# from somewhere that is NOT the proxy, claim to be someone elsecurl -s https://api.example.com/whoami -H 'X-Forwarded-For: 1.2.3.4'Expose a temporary endpoint that echoes what the application believes:
@app.get("/whoami")def whoami(request: Request): return {"client": request.client.host, "scheme": request.url.scheme}If that returns 1.2.3.4, the header is being trusted from your test machine and the
client IP is a claim rather than an observation. Remove the endpoint afterwards.
Check what the process is actually running with:
ps -o args= -C uvicorn 2>/dev/null || ps aux | grep '[u]vicorn'env | grep -i FORWARDED_ALLOW_IPSThe environment variable matters as much as the flag — it is read when the flag is absent, so a value set in a base image or a chart applies invisibly.
Only the last hop is yours to trust
Section titled “Only the last hop is yours to trust”X-Forwarded-For accumulates: each proxy appends, so the header is a list and the
entries on the left are the ones furthest from you. A client can pre-seed that list by
sending its own X-Forwarded-For, and a proxy that appends rather than replaces will
happily carry the fabricated entry along.
The value you can rely on is the one your proxy added — the rightmost. Anything further left was told to you by something you do not control.
If your proxy is an internet edge, the robust arrangement is for it to strip the inbound header entirely and write a fresh one, so nothing a client sends survives to the application. That is a change in the proxy’s configuration rather than in your code, and it is worth making, because it removes the ambiguity instead of compensating for it.
Setting --forwarded-allow-ips to a specific network breaks the moment the proxy
moves. A new node pool, a different CNI, a service mesh sidecar inserted in front —
any of these change the address the application sees, the headers stop being trusted,
and every client collapses into one bucket again.
That failure is silent. Nothing errors; the client IP is simply wrong from then on, and a rate limiter that suddenly treats all traffic as one visitor either throttles everyone or nobody.
Give yourself a signal. Log the observed peer alongside the resolved client on a sample of requests, and alert if they stop differing:
logger.info("peer=%s client=%s", request.scope.get("client"), request.client.host)When those two are always equal, forwarded headers are not being honoured.
Also verify the scheme after any change — request.url.scheme reading http behind a
TLS proxy is the same misconfiguration wearing different clothes, and it is what breaks
redirect URLs and Secure cookie logic.
Related
Section titled “Related”- Session cookies — the scheme confusion this causes, and what it does to
Secure - Rate limiting — the control that depends most directly on the client IP being real