Skip to content

ALLOWED_HOSTS and the leading-dot trap

Severity: highApplies to: Django 4.0+Applies to: Django 5.2 LTSApplies to: Django 6.0Facts last verified 2026-07-26 against Django 6.0 (main)

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.

the fix
settings.py
ALLOWED_HOSTS = [
"example.com",
".example.com", # leading dot = this domain AND every subdomain
]
verify it workedRun this in: framework cli
Terminal window
python manage.py check --deploy
if you see this
ALLOWED_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.W020
Invalid 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_host
Invalid 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_host

The 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.

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.

before you ship this

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.

  • Host header poisoning — what this setting prevents
  • CSRF trusted origins — the setting with the inverted syntax
  • The server in front of Django decides which Host values reach it at all. A catch-all virtual host that rejects unknown names is the layer above this one.