skip to content

Host Header Validation

ALLOWED_HOSTS gates request.get_host(), raising DisallowedHost, and url_has_allowed_host_and_scheme vets redirect targets. Interviewers ask how a forged Host header poisons password-reset links.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

4

In Django, what does the ALLOWED_HOSTS setting do, and why does a site start answering 400 Bad Request right after DEBUG is set to False?

level: juniorimportance: must knowfreq 66%

answer

  1. which domains may this site serve
  2. empty list plus DEBUG=False
  3. get_host() raises DisallowedHost
  4. debug-only localhost fallback

basics

~20 s

ALLOWED_HOSTS lists the domain names a Django site may serve; request.get_host() rejects any other Host with DisallowedHost, a 400. An empty list is only tolerated for localhost while DEBUG=True, so switching DEBUG off without filling it breaks every request.

solid answer

~40 s

`ALLOWED_HOSTS` is Django's allow-list of host names the site answers to. `HttpRequest.get_host()` reads the `Host` header, lowercases it, strips the port and a trailing dot, and checks it: an exact entry matches exactly, an entry with a leading dot like `'.example.com'` matches the domain and every subdomain, and `'*'` matches anything. A miss raises `DisallowedHost`, a `SuspiciousOperation`, which the handler turns into a 400. The default is `[]`, but while `DEBUG = True` an empty list is replaced by `['.localhost', '127.0.0.1', '[::1]']`. Flip `DEBUG` to `False` and that fallback disappears, so with the default middleware (`CommonMiddleware` calls `get_host()` on every request) the site answers 400 to everything. `runserver` refuses to start in that state; a production server such as gunicorn starts and fails per request.

code

python · 10 lines
python
# settings/production.py
import os

DEBUG = False
ALLOWED_HOSTS = [
    host.strip()
    for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
    if host.strip()
]
# e.g. DJANGO_ALLOWED_HOSTS="www.example.com,example.com"

go deeper

for a junior

Know that ALLOWED_HOSTS must list your real domain names once DEBUG is False, and that forgetting it produces 400 Bad Request on every page.

for a middle

Explain the three entry forms, that ports are ignored, the localhost fallback under DEBUG, and that DisallowedHost is a SuspiciousOperation mapped to 400.

for a senior

Show you load the list per environment, handle load-balancer health checks that probe by IP, and refuse '*' unless another layer validates the host first.

for a principal

Frame ALLOWED_HOSTS as one layer of host trust alongside proxy configuration, and decide who owns the host list across many deployments.

## What the setting is for Every HTTP/1.1 request carries a **`Host` header**, and the client chooses its value. Django uses the host to build absolute URLs: redirects, `build_absolute_uri()`, links in emails, the current site. If an attacker can put any host there, those URLs can point at the attacker. **`ALLOWED_HOSTS`** is the allow-list that stops it: a list of strings naming the host/domain names this Django site may serve. Its default in `django/conf/global_settings.py` is an empty list, `[]`. ## How a host is matched The check lives in **`HttpRequest.get_host()`**. It takes the raw host (from `Host`, or from `X-Forwarded-Host` only when `USE_X_FORWARDED_HOST = True`), splits off the port, lowercases the domain and strips a trailing dot, then compares it with each entry: | Entry | Matches | |---|---| | `'www.example.com'` | exactly that name, case-insensitive, any port | | `'.example.com'` | `example.com` and every subdomain such as `api.example.com` | | `'*'` | anything, so you must validate the host yourself | A few details interviewers probe: - Ports are **not** part of the match, so `'example.com'` accepts `example.com:8000`. - There is no glob syntax: `'*.example.com'` is treated as an exact string and matches nothing useful. - A host that is not a syntactically valid domain fails even before the list is consulted. ## What happens on a miss `get_host()` raises **`DisallowedHost`**, a subclass of **`SuspiciousOperation`**. Django's exception handler converts any `SuspiciousOperation` into a **400 Bad Request** and logs it to the `django.security.DisallowedHost` logger rather than `django.request`. With `DEBUG = True` you see the technical error page with status 400 and a hint such as "You may need to add 'shop.example.com' to ALLOWED_HOSTS". ## Why DEBUG=False suddenly breaks the site The surprise comes from a **debug-only fallback**. When `DEBUG = True` and `ALLOWED_HOSTS` is empty, `get_host()` validates against `['.localhost', '127.0.0.1', '[::1]']`, so local development works with the setting untouched. Setting `DEBUG = False` removes that fallback, and an empty list then matches nothing. The typical sequence: 1. A developer deploys with `DEBUG = False` and leaves `ALLOWED_HOSTS = []`. 2. A request arrives with `Host: shop.example.com`. 3. `CommonMiddleware.process_request()`, which is in the default `MIDDLEWARE` list, calls `request.get_host()` for its `PREPEND_WWW` check. 4. `DisallowedHost` is raised and the client gets a 400 for every page. Two related behaviours help diagnose it: - **`manage.py runserver`** refuses to start with `CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False.` A WSGI or ASGI server starts normally and fails request by request, which is why the bug shows up only in production. - The **test runner** appends `'testserver'` to `ALLOWED_HOSTS` during tests, so a test suite passes even when the production list is wrong. ## Diagnosing the 400 in production When a freshly deployed site answers 400 to everything, work through these checks: - **Read the security log.** The message on the `django.security.DisallowedHost` logger quotes the rejected host and suggests the entry to add. - **Compare what the proxy sends.** If a reverse proxy rewrites `Host` to an internal name such as `app:8000`, that internal name is what Django validates, not the public domain. - **Check the environment variable.** A typo or an unset variable leaves the list empty, which behaves exactly like forgetting the setting. - **Check `DEBUG` itself.** A site that worked on staging with `DEBUG = True` and an empty list was relying on the localhost fallback all along. The fix is almost never `'*'`; it is naming the host Django actually receives. ## Getting it right - List the real public names: `ALLOWED_HOSTS = ['www.example.com', 'example.com']`, usually read from an environment variable per deployment. - Use a leading dot only when every subdomain really belongs to this app. - Avoid `'*'`. The documentation says that if you use it you are responsible for validating the host yourself, for example in a middleware listed first in `MIDDLEWARE`. - Remember health checks: a probe that sends an IP address as its `Host` needs that IP listed, or it gets 400s. The setting is a guard, not a routing feature: Django does not serve different URLconfs per host because of it.

  • Why does the test suite pass even though production returns 400s?
    During tests Django's `setup_test_environment()` appends `'testserver'` to `ALLOWED_HOSTS`, the host the test client sends by default. The suite therefore never exercises the production list. A settings smoke test that asserts the expected names are present, or a deploy check, catches the gap.
  • Does ALLOWED_HOSTS = ['.example.com'] accept a request to example.com:8443?
    Yes. `get_host()` splits the port off before matching, and a leading-dot entry matches the bare domain as well as every subdomain. The port is kept in the value `get_host()` returns but plays no part in the allow-list check.

saying these in an interview costs you the question

  • ALLOWED_HOSTS is a CORS setting controlling which origins may call the API
  • Writing '*.example.com' to allow subdomains, as in a shell glob
  • Setting ALLOWED_HOSTS = ['*'] in production because it makes the 400s go away
  • Believing validation is simply skipped whenever DEBUG is True
  • Expecting a 500 or 403 rather than a 400 for a disallowed host
open as a page

In Django, where exactly is the Host header checked against ALLOWED_HOSTS, and why is reading request.META['HTTP_HOST'] directly a security bug?

level: middleimportance: should knowfreq 34%

basics

~10 s

Django validates the host only inside HttpRequest.get_host(); no middleware checks it up front. Code that reads request.META['HTTP_HOST'] or request.headers['Host'] gets the raw, attacker-chosen value and bypasses ALLOWED_HOSTS entirely.

open as a page

In a custom Django view, how do you safely redirect to a user-supplied ?next= URL, and what does url_has_allowed_host_and_scheme() reject?

level: middleimportance: should knowfreq 40%

basics

~10 s

Check the value with django.utils.http.url_has_allowed_host_and_scheme(url, allowed_hosts={request.get_host()}, require_https=request.is_secure()) and fall back to a fixed URL when it returns False. It rejects foreign hosts, non-HTTP schemes and browser-parsing tricks.

open as a page