Troubleshooting
Settings & secrets

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.

Where a bare 400 comes fromRequest arrivesHost, body, headersDjango validatesbefore your viewRejects requesthost, size, fieldsBare 400 pagereason only in logsDEBUG off hides the reason from visitors; turn on django.security logging to see it.
  1. Log django.security and django.request to the console to see the reason.
  2. Add every public hostname to ALLOWED_HOSTS (and the platform hostname on PaaS); this is the cause in most cases.
  3. Make sure Nginx forwards the real Host header (proxy_set_header Host $host;).
  4. Allow health check hosts (pod IPs, localhost) or have probes send a valid Host.
  5. 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.

what the django.security log saysLog messageFixInvalid HTTP_HOST headeradd the hostname to ALLOWED_HOSTShost is 127.0.0.1 or a socketNginx not forwarding Host; set ithost is a pod or private IPhealth probe Host; allowit or send a headerTooManyFieldsSenthuge admin form; raiseDATA_UPLOAD_MAX_NUMBER_FIELDSRequestDataTooBigbody over the limit; raiseit or stream uploadsNearly every bare 400 is logged as a SuspiciousOperationdjango-deployment.com
Turn on django.security logging and the message names the exact cause.

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 Host header 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/ before CommonMiddleware validates 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.

from bare 400 to cause1Log django.security to consolereason becomes visible2Reproduce and read app logsjournalctl or compose logs3Fix ALLOWED_HOSTS or Host headerthe usual cause4Handle health check hostsprobe header or Nginx answer5curl with each public hostnameall return 200Never debug a 400 by switching DEBUG on in productiondjango-deployment.com
Logging shows the same reason DEBUG would, without exposing settings to visitors.

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 Host headers generate DisallowedHost noise; reject unknown hosts in Nginx with a default server that returns 444.
  • Custom 400 template. Add 400.html to show a friendlier page; it does not change the cause.
  • CSRF failures return 403, not 400; see the CSRF guide.
  • Ports. ALLOWED_HOSTS entries match without the port, so example.com covers example.com:8443.

Next steps

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.

Keep going