Trusted hosts
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_hostsallowed_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 hostThe middleware is present, the request passes, and a scan of your middleware stack shows host checking is “on”.
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.
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:
curl -s -o /dev/null -w '%{http_code}\n' \ --resolve 'evil.example:443:203.0.113.10' https://evil.example/allowed_hostsoptionHost 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, ...)
www_redirectoptionRedirects 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, ...)
Invalid host headerTrustedHostMiddleware 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.pyThe proxy may already be doing this
Section titled “The proxy may already be doing this”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.
What it does not protect
Section titled “What it does not protect”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.
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:
grep -ohiE '^host: .*' access.log | sort | uniq -c | sort -rn | head -20A 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.
Related
Section titled “Related”- 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