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?
answer
- which domains may this site serve
- empty list plus DEBUG=False
- get_host() raises DisallowedHost
- debug-only localhost fallback
basics
~20 sALLOWED_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# 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
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.
Explain the three entry forms, that ports are ignored, the localhost fallback under DEBUG, and that DisallowedHost is a SuspiciousOperation mapped to 400.
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.
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