ProxyFix reads X-Forwarded-For from the right
Flask does not read X-Forwarded-* headers. Behind a proxy that means
request.remote_addr is the proxy, request.scheme is http, and anything built on
either is wrong — rate limits keyed to one address, Secure cookies that never send,
audit logs full of your own load balancer.
The fix is ProxyFix, a Werkzeug WSGI middleware, and the whole control is the numbers
you pass it.
from werkzeug.middleware.proxy_fix import ProxyFix
# One proxy in front of this app, setting X-Forwarded-For and X-Forwarded-Proto.app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)ProxyFixoptionHow many trailing values to trust in each X-Forwarded-* header. Not a boolean and not a hop index from the left — Werkzeug takes the nth value counting from the RIGHT, which is the end your own proxy wrote. Host, port and prefix are off by default, so ProxyFix() alone does not fix request.host.
- accepts
x_for|x_proto|x_host|x_port|x_prefix- default
x_for=1, x_proto=1, x_host=0, x_port=0, x_prefix=0- set in
app.wsgi_app
The numbers count from the right
Section titled “The numbers count from the right”This is the part worth reading twice. x_for=1 does not mean “the first entry” and it
does not mean “on”. It means take the first value counting back from the end of the
header — the end your own proxy appended, and the only part a client cannot write.
That is the opposite end from the one Django reads. Django’s
SECURE_PROXY_SSL_HEADER takes the leftmost comma-separated value; Werkzeug takes
from the right. Same header, same deployment, opposite convention — measured on both:
X-Forwarded-Proto received |
Flask request.scheme |
Django request.scheme |
|---|---|---|
http,https |
https — rightmost |
http — leftmost |
https,http |
http — rightmost |
https — leftmost |
Neither is wrong. They are answering different questions — Werkzeug asks what the nearest proxy said, Django asks what the client’s own connection was — and a value copied between the two frameworks means the opposite of what it did in the other.
What a wrong count does
Section titled “What a wrong count does”Measured on Flask 3.1.3 / Werkzeug 3.1.8, one real proxy in front, with
X-Forwarded-For: 203.0.113.7 and X-Forwarded-Proto: https:
| Configuration | request.remote_addr |
request.scheme |
|---|---|---|
no ProxyFix at all |
10.0.0.1 — the proxy |
http |
ProxyFix() — the default x_for=1 |
203.0.113.7 |
https |
ProxyFix(x_for=2) |
10.0.0.1 |
https |
ProxyFix(x_for=0) |
10.0.0.1 |
https |
Then the same app with a client that sent its own entry first, so the header arrives as
1.2.3.4, 203.0.113.7:
| Configuration | request.remote_addr |
|
|---|---|---|
ProxyFix() — x_for=1 |
203.0.113.7 |
correct |
ProxyFix(x_for=2) |
1.2.3.4 |
the client’s forged value |
Two things follow, and the second is the useful one.
Counting too high reaches into client-controlled data, exactly as it does in any
framework that trusts a hop count. x_for=2 behind a single proxy reads the entry the
client wrote.
But Flask tells you. With no forged header present there simply is no second value,
so ProxyFix falls back to the socket address and your logs immediately fill with
10.0.0.1 instead of real client addresses. The misconfiguration is visible in ordinary
traffic on the first request. That is a genuinely better failure mode than the
equivalent mistake in Express, where an over-counted hop
returns the correct address right up until somebody forges one.
So the Flask version of this bug is usually caught by someone noticing their analytics went flat — not by an incident.
# what the app believes, with and without a forged entrycurl -s https://example.com/whoamicurl -s https://example.com/whoami -H 'X-Forwarded-For: 1.2.3.4'Expose request.remote_addr on a temporary route. The first call should show a real
client address — if it shows your proxy, the count is too high or ProxyFix is missing.
The second should be unchanged; if 1.2.3.4 appears, the count is too high in the
dangerous direction.
x_host and x_prefix are off, and that surprises people
Section titled “x_host and x_prefix are off, and that surprises people”ProxyFix() with no arguments sets x_for=1, x_proto=1 and leaves
x_host=0, x_port=0, x_prefix=0. So request.host still reflects what reached the
application, not X-Forwarded-Host, and url_for(..., _external=True) keeps generating
the internal hostname. Adding x_host=1 fixes that and simultaneously makes the host a
client-influenced value again, which is why trusted hosts is
the setting that has to come with it.
Adding ProxyFix changes what every IP-based control sees at once. Rate-limit buckets
re-key from one shared proxy address to real client addresses, so existing counters
reset. Anything that allow-lists an address starts matching a different value.
x_proto=1 also makes request.is_secure true for the first time, which is what you
want — and which means SESSION_COOKIE_SECURE starts actually applying, so a
misconfigured proxy that does not set X-Forwarded-Proto will now leave sessions
broken rather than merely insecure.
Never apply ProxyFix to an application reachable without going through the proxy. The
middleware trusts the header unconditionally; it is the network path that makes the
header true, and nothing in Flask can check that.
Related
Section titled “Related”- Trusted hosts — required before
x_hostis safe to turn on - Session cookies — what
x_protomakes work - Running in production — the WSGI server and proxy this assumes
- Application vs server — the proxy half of this control