Skip to content

ProxyFix reads X-Forwarded-For from the right

Severity: mediumApplies to: Flask 3.1.3Applies to: Werkzeug 3.1.8Facts last verified 2026-08-16 against Flask 3.1.3 · Werkzeug 3.1.8

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.

the fix
app.py
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)
settings on this page
ProxyFixoption

How 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
Read in: werkzeug 3.1.8 werkzeug/middleware/proxy_fix.py — ProxyFix.__init__; reproduced

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.

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.

verify it workedRun this in: shell
Terminal window
# what the app believes, with and without a forged entry
curl -s https://example.com/whoami
curl -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.

before you ship this

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.