ALLOWED_HOSTS and the leading-dot trap
ALLOWED_HOSTS defaults to [] and exists to stop HTTP Host header attacks, which
Django’s own documentation notes are “possible even under many seemingly-safe web
server configurations”. What that means in practice is on the
host header poisoning page.
ALLOWED_HOSTS = [ "example.com", ".example.com", # leading dot = this domain AND every subdomain]python manage.py check --deployALLOWED_HOSTS must not be empty in deployment.The check is literally `return [] if settings.ALLOWED_HOSTS else [W020]` — it tests emptiness only. A list containing `"*"` passes it while validating nothing.
Read in: django/core/checks/security/base.py — check_allowed_hosts, id security.W020Invalid HTTP_HOST header: %r. You may need to add %r to ALLOWED_HOSTS.A request arrived whose Host header matched no entry. Django names the domain it wanted, so the fix is usually visible in the message itself.
Read in: django/http/request.py — HttpRequest.get_hostInvalid HTTP_HOST header: %r. The domain name provided is not valid according to RFC 1034/1035.The second branch of the same exception — the header was not merely unlisted, it was malformed. Usually a scanner rather than a misconfiguration.
Read in: django/http/request.py — HttpRequest.get_hostThe leading dot means the opposite of what it means one setting over
Section titled “The leading dot means the opposite of what it means one setting over”This is the trap, and it catches people who have just fixed the other setting:
| Value | ALLOWED_HOSTS |
CSRF_TRUSTED_ORIGINS |
|---|---|---|
.example.com |
subdomain wildcard — correct | rejected since Django 4.0 |
https://*.example.com |
never matches — no scheme is parsed here | subdomain wildcard — correct |
Two settings, both lists of domains, both about trusting origins, with inverted
wildcard syntax and no shared validation. ALLOWED_HOSTS compares against the Host
header, which carries no scheme, so a scheme-prefixed value simply never matches.
CSRF_TRUSTED_ORIGINS compares against Origin, which
always carries one.
Nothing warns you. A value in the wrong format is not invalid — it is just a string that never matches, and the failure shows up as rejected requests in production.
"*" passes the deploy check and validates nothing
Section titled “"*" passes the deploy check and validates nothing”The check tests only that the list is non-empty. ALLOWED_HOSTS = ["*"] therefore
gets a clean check --deploy while accepting every Host header — Django’s docs are
explicit that with "*" “you are responsible to provide your own validation”.
If you inherited a project with ["*"] in it, that is an unset control wearing a
passing check.
The bypass worth knowing about
Section titled “The bypass worth knowing about”Django’s docs state it plainly: this validation applies via request.get_host()
only. Code that reads request.META["HTTP_HOST"] directly is “bypassing this security
protection”. So is any library that builds absolute URLs from the raw header.
Grep for it. A single request.META["HTTP_HOST"] in a password-reset flow undoes the
entire setting.
Also note USE_X_FORWARDED_HOST (default False). Turning it on makes Django trust
X-Forwarded-Host instead — which is correct behind a proxy that sets it, and a
free-for-all behind one that does not strip it from client requests.
Getting this wrong takes the site down rather than leaving it insecure, which is the better failure direction but still a failure. Every hostname that reaches Django must be listed: the public domain, any load-balancer health-check host, and the internal service name if a container reaches it that way.
Health checks are the usual casualty — they often request by IP, and an IP is not
covered by .example.com.
Related
Section titled “Related”- Host header poisoning — what this setting prevents
- CSRF trusted origins — the setting with the inverted syntax
- The server in front of Django decides which
Hostvalues reach it at all. A catch-all virtual host that rejects unknown names is the layer above this one.