A Better ALLOWED_HOSTS

Klaas van Schelven
Klaas van Schelven; July 21 - 5 min read
Carol from Little Britain 'says no' with a 400 error page in the background
“ALLOWED_HOSTS says no”: the default error page is a blank 400 response.

If you’ve deployed Django, you’ve probably hit ALLOWED_HOSTS. It’s easy to misconfigure, and the error message rarely helps.

Bugsink is a Django app people install on their own servers – and those people are not always Django developers. That makes configuration errors easier to trigger and harder to excuse. With a promised install time of 30 seconds, there’s not much budget for getting it wrong.

This article shows what I did in Bugsink to make ALLOWED_HOSTS fail less, and more clearly. The details might apply to your own Django project, too.

TL;DR: Bugsink uses BASE_URL to set the expected hostname, shows the actual Host and ALLOWED_HOSTS value when there’s a mismatch, and displays the error in the browser — making ALLOWED_HOSTS issues less likely and easier to fix.

Why Does ALLOWED_HOSTS Exist?

ALLOWED_HOSTS protects your app when something upstream sends the wrong Host header. Without it, an attacker could spoof the header and trick Django into generating links, redirects, or content under the wrong domain. For example, Django might:

  • Generate a password reset link using a domain the attacker controls
  • Send a redirect or email that points to the wrong site
  • Poison caches with cross-host content

Django used to suggest relying on the proxy or web server to block forged Host headers. But they’ve since acknowledged that many “seemingly safe” setups don’t actually do this well. So Django now performs the check itself, using the ALLOWED_HOSTS setting.

I once publicly asked whether Django isn’t simply taking on too much responsibility here but have since come to accept that “defense in depth” is the correct call here (I’ve run into one of those “seemingly safe” setups myself).

That isn’t to say that ALLOWED_HOSTS is perfect. Where Django still falls short is in how it handles the error when ALLOWED_HOSTS is misconfigured. The failure results in a blank 400 page, the exception is hidden, and even if you surface the message, it often leads you in the wrong direction.

In the below, I’ll show how Bugsink improves on the error message. But first: how to avoid the error in the first place.

Deducing ALLOWED_HOSTS

Bugsink already requires a BASE_URL setting, which is used for generating links in code that runs outside the request/response loop, such as notifications. That is: because not all code runs in the context of a request, Bugsink must be told where it lives, so it can generate links that point to the right place. This “somewhere” is BASE_URL, which is set to something like https://your.host.example.com.

Since BASE_URL is already a required setting, I decided to reuse it to derive the value for ALLOWED_HOSTS. I added a function that takes the BASE_URL, extracts the hostname, and returns a list of allowed hosts based on that.

That function also handles a few extra cases:

If the hostname in BASE_URL is localhost or 127.0.0.1, it assumes you’re running locally – and that you might be using a different hostname for convenience: i.e. because you’re running Bugsink in a Docker container, or have defined an alias in /etc/hosts. In that case, Bugsink switches to a permissive mode and just returns ["*"]. That’s safe: if you’ve told Bugsink it lives at localhost, you’re clearly not in production.

If the hostname is something else, it’s assumed to be a production environment. In that case, the function returns a list containing the hostname from BASE_URL.

In addition, localhost and 127.0.0.1 are added to the list. This is to allow access to the site which does not provide a Host header, such as health checks from load balancers or internal service calls. In other words, access to the site which does not go through the proxy (which should be setting the Host header correctly).

That’s safe, because the exploits that spoof Host headers rely on setting the header to a value the attacker controls, like Host: attacker.com. But loopback addresses like localhost and 127.0.0.1 are never attacker-controlled (if someone could hijack those, you’d already have much bigger problems). Allowing them avoids false blocks without weakening security.

The full logic lives here in the source.

Better Error Messages

Even with a better default, things can still go wrong. If your proxy is misconfigured and doesn’t send the right Host header, you’ll run into the same error – even if ALLOWED_HOSTS is correct. And Bugsink still allows you to override the default entirely, so a manual misconfiguration is always possible.

If that happens, Django’s default error message looks like this:

Invalid HTTP_HOST header: ‘localhost:8000’. You may need to add ‘localhost’ to ALLOWED_HOSTS.

This message is opaque, generic and misleading. First, the underlying cause is not revealed, although in reality it can only be one of two things (or both):

  • A bad Host header in the request
  • A missing value in ALLOWED_HOSTS

Since Django doesn’t tell you what the values are, you have to guess where the mismatch might be coming from, i.e. which side of the equation is wrong.

Moreover, the suggestion to add localhost to ALLOWED_HOSTS is almost always wrong. In the most common case — a proxy connecting over a loopback address without setting a proper Host header — the error recommends adding localhost, which no production server actually uses. So the advice always points in the wrong direction.

Bugsink replaces the default error-message with one that:

  • Shows the actual Host header and the current ALLOWED_HOSTS
  • Suggests a fix based on whether the mismatch looks like a proxy error or a config mistake

Here’s the logic:

def allowed_hosts_error_message(domain, allowed_hosts):
    msg = f"'Host: {domain}' as sent by browser/proxy not in ALLOWED_HOSTS={allowed_hosts}. "

    suggestable_hosts = [
        h for h in allowed_hosts
        if h not in ["localhost", ".localhost", "127.0.0.1"]
    ]
    proxy_hint = " | ".join(suggestable_hosts) or "your.host.example"

    if domain == "localhost" or is_ip_address(domain):
        return msg + (
            f"Configure proxy to use 'Host: {proxy_hint}' "
            "or add the desired host to ALLOWED_HOSTS."
        )

    return msg + (
        f"Add '{domain}' to ALLOWED_HOSTS or configure proxy "
        f"to use 'Host: {proxy_hint}'."
    )

The function distinguishes between two situations:

  • If the incoming host is a loopback address like localhost or 127.0.0.1, it’s almost certainly a proxy misconfiguration. Bugsink suggests to fix that.
  • If the host is a real domain, it’s more likely a mistake in ALLOWED_HOSTS. In this case the message nudges you to add the domain to ALLOWED_HOSTS (although it still suggests checking the proxy).

This avoids the confusion from Django’s default message: instead of blindly suggesting adding localhost to ALLOWED_HOSTS, it shows the actual Host header, the current ALLOWED_HOSTS, and suggests the most plausible fix – whether that’s correcting the proxy or updating the setting.

Plugging It In

To hook this up, Bugsink subclasses Django’s WSGIRequest and uses a custom WSGIHandler that references it. Here’s the relevant bits of bugsink/wsgi.py:

class CustomWSGIRequest(WSGIRequest):
    # hook that allows multiple customizations, of which the
    # allowed_hosts_error_message is one

    def get_host(self):
        # the customizations go here


class CustomWSGIHandler(WSGIHandler):
    request_class = CustomWSGIRequest


def custom_get_wsgi_application():
    # Like get_wsgi_application, but returns a subclass of WSGIHandler that uses a custom request class.
    django.setup(set_prefix=False)
    return CustomWSGIHandler()


application = custom_get_wsgi_application()

Note that there’s also a reference to this machinery in the WSGI_APPLICATION setting.

Show the Error On-Screen

A good error message doesn’t help much if you can’t see it. So in Bugsink, I override Django’s default 400 handler to make sure the message is actually shown directly in the browser.

I’d argue the browser is the best place to show this error. In the process of deploying Bugsink, you’re already looking at the browser to check whether you managed to install it correctly. i.e. to even trigger the error, you’d have to access the site in the browser. So it makes sense to just show it there too.

The alternative of just logging the error to some location is much worse from the perspective of the person installing Bugsink: it puts them in the position of having to guess that logs exist and figure out where they are. This might just be the moment they give up and decide that Bugsink is too hard to install.

The implementation is straightforward: I override Django’s default 400 handler to render the exception message into a minimal HTML template and return it as the response.

Security Considerations

In terms of security, I think this is a reasonable tradeoff. The error page only shows the incoming Host and current ALLOWED_HOSTS. The incoming Host header is either:

  • The very Host header that the attacker managed to sneak past your proxy, in which case echoing it back reveals nothing they didn’t already control

  • A misconfiguration (e.g. your proxy isn’t setting the correct header), in which case you don’t have a functioning site yet anyway (so the information exposure is limited to the setup phase, when the app isn’t yet serving real data).

Regarding the current ALLOWED_HOSTS, it does reveal information: if someone accesses your site by IP with the wrong Host header, they can see which hostname you’re expecting. But in any secure setup, your site is protected by SSL, and the certificate (which is sent as part of the TLS handshake) already exposes the hostname. So in the end no real information is leaked that an attacker couldn’t already see.

You’ll have to decide whether this tradeoff makes sense in your setup. If you’re in a larger organization, working with a separate operations team, or under compliance constraints, you might prefer not to expose any error detail in the browser – even during setup. Also: in your case, the number of people who have to install your app is likely much smaller (typically: just your team), so the pain isn’t amplified the way it is with Bugsink.

Note: after writing this up, I posted [parts of] the idea to the Django Bug Tracker for possible inclusion in Django itself. However, it was closed with following comment (which I agree with in the general case, and which may or may not apply to your case):

In real world deployments, it’s common to include internal hostnames, IP addresses, or ephemeral domains that are not externally visible, for example in environments where SSL termination and routing are handled separately from the Django app itself.

Conclusion

When Django blocks a request due to ALLOWED_HOSTS, the cause is usually straightforward — but the default error page doesn’t show it.

In Bugsink, I avoid the issue by extracting the expected hostname from the existing BASE_URL setting. And if the check still fails, the browser shows a clear message with the received Host and current configuration. No need to guess, check logs, or know Django internals.

From a security perspective, there’s a tradeoff — but the page only reveals information already visible to any client, like the Host header or the domain in the TLS certificate.