Skip to content

Trusted hosts

Severity: mediumApplies to: Flask 3.1+Applies to: Flask 3.1.3Facts last verified 2026-08-13 against Flask 3.1.3

Host is a request header, so it is client-controlled. Anything derived from it is client-controlled too — most importantly url_for(..., _external=True), which is what builds password reset links and confirmation URLs.

The classic consequence is a reset email whose link points at a host the attacker chose, carrying a token that is valid for your application.

Flask 3.1 added TRUSTED_HOSTS to check this. It defaults to None, which means no check.

With TRUSTED_HOSTS = ["example.com"]:

Host: example.com -> 200
Host: evil.test -> 400

The check happens early, before the view runs, so a rejected request never reaches your code.

settings on this page
TRUSTED_HOSTS

Host values Flask will serve; None means no validation at all. The trap is the version boundary rather than the value — this key is new in Flask 3.1, and on 3.0 or earlier Flask silently ignores unknown config keys, so the exact same line reads like a working control and does nothing.

accepts
["example.com"] | [".example.com"] (subdomain wildcard) | None
default
None
set in
app.config
Read in: Flask 3.1.3 app.config, read from a running app; reproduced — matching Host returns 200, evil.test returns 400
the fix
app.config["TRUSTED_HOSTS"] = ["example.com", "www.example.com"]

Setting SERVER_NAME is the older habit and it is not the same control. SERVER_NAME tells Flask what hostname to use when generating external URLs outside a request context, and it also constrains routing in ways that surprise people — it is a URL generation setting that happens to have host-shaped side effects. TRUSTED_HOSTS is the validation.

If you generate URLs from background jobs you will want both, and they are answering different questions.

verify it workedRun this in: http response
Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.test' https://app.example.com/

400 is correct. 200 means the header is not being validated.

Check the config, since a None here is easy to miss:

Terminal window
python3 -c "from app import app; print('TRUSTED_HOSTS =', app.config.get('TRUSTED_HOSTS'))"

And find what is generating external URLs, since that is where a forged host does its damage:

Terminal window
grep -rn '_external=True' --include='*.py' --include='*.html' . | head -20

Each of those builds an absolute URL from the request’s host unless SERVER_NAME is set.

TRUSTED_HOSTS does not exist before 3.1. Setting it on an older version is silently ignored — Flask does not reject unknown config keys, so the line looks effective and does nothing.

That is worth checking before you rely on this page:

Terminal window
python3 -c "import flask; print(flask.__version__)"

On an older version the options are to upgrade, to validate in a before_request hook, or to have the proxy reject unknown hostnames — which many deployments already do.

If a reverse proxy in front of you refuses unknown hostnames — a default server block that closes the connection, or a load balancer routing strictly by host rule — then forged Host headers never arrive, and this control is a second layer.

Worth establishing which situation you are in, because it changes this from an open hole to defence in depth. It also changes where the failure appears when the list is wrong: a proxy-level rejection looks nothing like Flask’s 400.

Two layers is still right. The proxy covers what clients reach; TRUSTED_HOSTS covers anything that reaches the application port directly.

before you ship this

An incomplete list returns 400 to real users, and Flask’s 400 page is unbranded enough to read as a total outage.

The hostnames that get missed are the ones nobody thinks of as hostnames: the health check probing by container IP, the internal service DNS name other services use, localhost from a sidecar, and the platform’s own generated hostname.

A health check failing this way is the worst version — the probe fails, the orchestrator restarts the container, and it presents as a crash loop rather than a config problem.

Enumerate from live traffic first:

Terminal window
grep -ohiE '^host: .*' access.log | sort | uniq -c | sort -rn | head -20

Include every hostname that appears with meaningful volume, plus the probe’s, before turning it on.