Fix Django "Bad Request (400)" With DEBUG Off
Fix the plain Bad Request (400) page Django returns in production: DisallowedHost from ALLOWED_HOSTS, invalid HTTP_HOST from health checks, oversized or malformed requests, missing CSRF origins, and how to log the real reason.
Problem statement
After deploying with DEBUG = False, every page (or some requests) shows only:
Bad Request (400)
No traceback, no styled error page, nothing in the browser console. Django returns this bare 400 when it rejects a request as invalid or suspicious before your view runs. With DEBUG = True you would see the reason; with it off, Django hides details from visitors and logs them instead, often to a logger nobody has configured. The reason is almost always one of a short list, led by ALLOWED_HOSTS.
- Log
django.securityanddjango.requestto the console to see the reason. - Add every public hostname to
ALLOWED_HOSTS(and the platform hostname on PaaS); this is the cause in most cases. - Make sure Nginx forwards the real
Hostheader (proxy_set_header Host $host;). - Allow health check hosts (pod IPs,
localhost) or have probes send a validHost. - Check request size limits (
DATA_UPLOAD_MAX_NUMBER_FIELDS,DATA_UPLOAD_MAX_MEMORY_SIZE) for large forms.
Step-by-step solution
1. Make Django tell you why
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django.security": {"handlers": ["console"], "level": "WARNING", "propagate": False},
"django.request": {"handlers": ["console"], "level": "WARNING", "propagate": False},
},
}
Then reproduce and read the app logs (journalctl -u gunicorn, docker compose logs web):
django.security.DisallowedHost: Invalid HTTP_HOST header: 'www.example.com'. You may need to add 'www.example.com' to ALLOWED_HOSTS.
2. Fix ALLOWED_HOSTS
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") # e.g. example.com,www.example.com
Include every name the site is reached by: apex and www, API subdomains, and platform hostnames such as myapp.onrender.com or .fly.dev. A leading dot (.example.com) matches the domain and all subdomains. If the environment variable is missing and the default is an empty list, every request with DEBUG = False is a 400.
3. Forward the Host header
If the log shows Invalid HTTP_HOST header: '127.0.0.1:8000' or 'unix:/run/gunicorn.sock', Nginx is sending its upstream address as the host:
location / {
proxy_pass http://unix:/run/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
Do not fix this by adding 127.0.0.1 to ALLOWED_HOSTS for public traffic; that hides the real problem and breaks absolute URLs.
4. Health checks and probes
Load balancers and Kubernetes probes connect to the instance IP, so Host is something like 10.0.3.17:8000. Options:
- Configure the probe to send a
Hostheader with a real hostname. - Answer health checks in Nginx before Django (
location = /health { return 200; }), if checking Django itself is not required. - Use middleware that serves
/health/beforeCommonMiddlewarevalidates the host.
Avoid ALLOWED_HOSTS = ["*"] in production; it disables Host header validation, which protects against cache poisoning and password-reset link poisoning.
5. Large forms and bodies
Admin pages with thousands of inline fields can exceed DATA_UPLOAD_MAX_NUMBER_FIELDS (1,000 by default), and large non-file bodies can exceed DATA_UPLOAD_MAX_MEMORY_SIZE (2.5 MB). Both return 400 and log a SuspiciousOperation. Raise the limits for the specific need, or redesign the form; see Fix Django RequestDataTooBig.
Verification check:
for h in example.com www.example.com api.example.com; do curl -s -o /dev/null -w "$h %{http_code}\n" https://$h/; done
Each should return 200 or a redirect, and the logs should show no new DisallowedHost entries for legitimate hosts.
Explanation
Django validates the Host header on every request against ALLOWED_HOSTS when DEBUG is off, and raises SuspiciousOperation subclasses for invalid hosts, oversized requests, and similar problems. These are converted to a 400 response. To avoid leaking information, the production response contains no detail, and the reason goes to the django.security logger. Django's default logging configuration does not send those warnings anywhere visible when DEBUG is off, which is why the 400 looks unexplained until logging is configured.
Edge cases / notes
- Spam hosts. Bots sending random
Hostheaders generateDisallowedHostnoise; reject unknown hosts in Nginx with a default server that returns 444. - Custom 400 template. Add
400.htmlto show a friendlier page; it does not change the cause. - CSRF failures return 403, not 400; see the CSRF guide.
- Ports.
ALLOWED_HOSTSentries match without the port, soexample.comcoversexample.com:8443.
Next steps
- Deep dive on host validation: Fix DisallowedHost in Django Production.
- Other DEBUG-dependent failures: Fix Django Works With DEBUG True but Fails With DEBUG False.
FAQ
Why does Django show Bad Request (400) only in production?
With DEBUG = False, Django enforces ALLOWED_HOSTS and hides error details, so a host mismatch appears as a bare 400.
Is ALLOWED_HOSTS = "*" acceptable?
Not in production. It disables Host header protection against cache and password-reset poisoning.
How do I see the reason for the 400?
Send the django.security logger to the console and read the app logs.
Why do health checks get 400?
They use the instance IP as the Host. Send a proper Host header or answer them before Django.