Skip to content

Trusted hosts

Severity: mediumApplies to: Starlette 1.xApplies to: FastAPI 0.100+Facts last verified 2026-08-13 against Starlette 1.6.0 · FastAPI 0.141.1

Host is a request header, which means it is client-controlled. Anything your application builds from it — a password reset link, an absolute URL in an email, a redirect target — is client-controlled too, unless something checks the header first.

Starlette ships TrustedHostMiddleware to do that check. It is not installed by default, so on a stock FastAPI application nothing validates Host at all.

Installing it is not the same as configuring it

Section titled “Installing it is not the same as configuring it”

From the middleware’s own source:

def __init__(self, app, allowed_hosts: Sequence[str] | None = None, www_redirect: bool = True):
if allowed_hosts is None:
allowed_hosts = ["*"]
self.allow_any = "*" in allowed_hosts

allowed_hosts=None becomes ["*"], which sets allow_any and skips the check entirely. So this line, which looks like it enables host validation, enables nothing:

app.add_middleware(TrustedHostMiddleware) # allows every host

The middleware is present, the request passes, and a scan of your middleware stack shows host checking is “on”.

the fix
from starlette.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["api.example.com", "example.com"],
)

A wildcard is permitted for subdomains — "*.example.com" — and matches one level. It does not match the apex, so list the apex separately if you serve it.

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

400 is the correct answer. 200 means the header is not being checked.

Because you are overriding Host on a request that still needs to route to your server, use --resolve if the 400 is ambiguous:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' \
--resolve 'evil.example:443:203.0.113.10' https://evil.example/
settings on this page
allowed_hostsoption

Host values the app will serve. The default is the trap: None resolves to ["*"] and sets allow_any, so installing this middleware without arguments validates nothing while looking like a control. Reproduced — a bare install returned 200 for Host: evil.example.

accepts
["example.com"] | ["*.example.com"] | ["*"]
default
None
set in
app.add_middleware(TrustedHostMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/trustedhost.py — allowed_hosts=None then ['*']; reproduced
www_redirectoption

Redirects in one direction only — bare to www, never the reverse. The test is "www." + host == pattern, so it fires when the allow-list holds www.example.com and the request arrives as example.com, answering 307. List the bare domain and send www. and you get a 400, not a redirect. Set False to reject both.

accepts
True | False
default
True
set in
app.add_middleware(TrustedHostMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/trustedhost.py — the found_www_redirect branch; reproduced 307 -> http://www.example.com/, and 400 for the reverse
if you see this
Invalid host header

TrustedHostMiddleware rejecting a request whose Host did not match the allow-list. It is a plain-text 400 with no detail, which is correct but makes a misconfigured allow-list look like an outage — check the middleware before checking the application.

Read in: starlette starlette/middleware/trustedhost.py

If a reverse proxy in front of you rejects unknown hostnames — a default server block that returns 444 or 421, or a load balancer routing strictly by host rule — then requests with a forged Host never reach the application, and this middleware is a second layer rather than the only one.

That is worth checking before deciding how much this matters, because it changes the answer from “an exposure” to “defence in depth”. It also changes where the failure will appear when you get the list wrong: a proxy-level rejection looks nothing like Invalid host header.

Two layers is still the right arrangement. The proxy is what a client hits; the middleware is what protects you when something reaches the application port directly, which is the same scenario that makes proxy headers matter.

www_redirect defaults to True, so a request for example.com when www.example.com is allowed gets redirected rather than rejected. That is a convenience, and it is a redirect built from the allow-list rather than from the header, so it is safe — but it does mean a 301 is a possible response here, not just 200 or 400.

The middleware also does not touch X-Forwarded-Host. If something upstream rewrites Host from a forwarded header, the value this middleware validates is the one the proxy produced, and the client-supplied one was consumed earlier.

before you ship this

An incomplete allow-list returns 400 to real users, and because the response is a bare Invalid host header with no branding it reads as a total outage.

The hostnames that get missed are the ones nobody thinks of as hostnames: the health check probe (which often uses the pod IP, not a name), the internal service DNS name used by other services, localhost from a sidecar, and the load balancer’s own generated hostname.

Enumerate them from live traffic before enforcing:

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

A health check failing this way is the nastiest version — the probe fails, the orchestrator restarts the container, and it looks like a crash loop rather than a configuration problem. If your platform probes by IP, include the pod CIDR’s behaviour in your thinking or point the probe at a hostname you have allowed.

  • Proxy headers — the other request-header trust decision, and the one with more blast radius
  • CORS configuration — origin checking, which is a different header and a different question