Django ALLOWED_HOSTS on a Multi-Tenant Platform: Host Checks That Follow the Chatbots

Django’s ALLOWED_HOSTS setting is a fixed list, and Smarter serves a hostname for every chatbot that every account creates. This post describes how Smarter validates the HTTP Host header anyway, with a static list in Django’s settings that admits the platform’s own domains, a replacement for Django’s SecurityMiddleware that narrows that list to hostnames belonging to real chatbots, and the views that decide whether a chatbot is actually live. It ends with what each layer costs and whether the approach is worth copying.

Smarter is an open-source Django platform for building and running LLM applications. Each chatbot, a resource called an LLMClient, is served at a host of its own, <name>.<account-number>.<env>.api.<root>, with its own DNS record, Kubernetes Ingress and TLS certificate. A platform with many tenants can have thousands of them, created and deleted throughout the day, and no settings file can list them.

What the Host header can do to you

The Host header is chosen by the client. Django uses it to build absolute URLs, such as request.build_absolute_uri(), links in password-reset and notification emails, redirects, and anything else that needs to say where the site lives. If an attacker can make Django accept Host: evil.example, those URLs can point at the attacker. The well-known results are password-reset poisoning, where a victim’s reset link goes to the attacker’s server, and cache poisoning, where a shared cache stores a page built for the wrong host and serves it to everyone. In a multi-tenant platform there’s a third, which is a request that claims to be for one tenant’s host while being served in the context of another’s.

Django’s defense is ALLOWED_HOSTS. request.get_host() raises DisallowedHost for any host not on the list, and Django turns that into a 400 response. The trouble is that the list is static. Faced with dynamic tenant hostnames, many projects fall back to ALLOWED_HOSTS = ["*"] and turn the check off entirely, or to a leading-dot entry such as .api.example.com, which admits every subdomain, real or not.

The layers

LayerWhereRejects
1. Exact-host routingTraefik, one router per IngressAny host without an Ingress, before it reaches a pod
2. ALLOWED_HOSTSDjango settingsAnything outside the platform’s own domains
3. SmarterSecurityMiddlewareDjango middlewareA subdomain of the API domain that no LLMClient uses
4. The chat viewsDjango viewsAnonymous prompts to an LLMClient that isn’t deployed

The first layer is described in a companion post on per-host DNS and TLS. Because every chatbot has an Ingress for exactly its own hostname, Traefik has a router for each real host and nothing else, and a request whose Host header matches none of them gets Traefik’s 404. That’s what keeps the later layers affordable. By the time a request reaches Django, its Host has already matched an Ingress that Smarter created.

Four layers check a request's Host header, each shown with what it rejects. Traefik's exact-host routing turns away any host without an Ingress with its own 404. Django's ALLOWED_HOSTS rejects anything outside the platform's own domains with DisallowedHost, a 400. SmarterSecurityMiddleware returns 400 Invalid Hostname for an API subdomain no LLMClient uses. The chat views return 404 LLMClient not found for an anonymous prompt to an undeployed LLMClient. Health probes, ACME challenge paths and local development hosts skip layer 3.

Layer 2 is a static list of the platform’s own domains

Smarter’s settings are built by a Pydantic Settings class, and Django’s ALLOWED_HOSTS takes its value from there.

ALLOWED_HOSTS = smarter_settings.allowed_hosts

The property returns the environment’s platform domain, its API domain, the API domain with a leading dot, and whatever the operator adds through configuration.

retval = [
    self.environment_platform_domain,
    self.environment_api_domain,
    f".{self.environment_api_domain}",
] + default_allowed_hosts

The source is smarter/common/conf/settings.py, trimmed. Each entry is validated as a hostname when the settings load, so a malformed value stops the process at startup instead of quietly admitting nothing, or everything.

The leading-dot entry is the compromise. On its own, it would admit anything.api.example.com. That’s the gap the next layer closes.

Layer 3 admits only hosts that belong to an LLMClient

Smarter replaces Django’s django.middleware.security.SecurityMiddleware with a subclass of it.

MIDDLEWARE = [
    ...
    "smarter.apps.llmclient.middleware.security.SmarterSecurityMiddleware",
    ...
]

It overrides only the request half. Its process_request() lets a few kinds of request through unchecked, namely health and readiness probes, ACME challenge paths (/.well-known/acme-challenge/) and local development hosts. Everything else has to match a configured host pattern exactly, or belong to an LLMClient.

for allowed_host in smarter_settings.allowed_hosts:
    if fnmatch.fnmatch(host, allowed_host):
        return None

llmclient: Optional[LLMClient] = get_cached_llmclient_by_request(request=request)
if llmclient is not None:
    return None

logger.error("%s %s failed security tests.", logger_prefix, url)
return SmarterHttpResponseBadRequest(
    request=request, error_message="SecurityMiddleware() Bad Request (400) - Invalid Hostname."
)

The source is smarter/apps/llmclient/middleware/security.py, trimmed. The detail that makes this work is that fnmatch doesn’t treat a leading dot as a wildcard. Django reads .alpha.api.example.com as “this domain and every subdomain”. fnmatch reads it as a literal string that no real host equals. So the subdomains that Django’s list let in all fall through to the LLMClient lookup, and a subdomain that no LLMClient uses gets a 400 with a clear message.

Diagram of how the allowed-hosts entry .alpha.api.example.com is read two ways. Django's ALLOWED_HOSTS reads the leading dot as the domain and every subdomain, so anything.alpha.api.example.com gets in, while fnmatch in SmarterSecurityMiddleware reads it as a literal string no host equals, so every subdomain falls through to get_cached_llmclient_by_request(). If an LLMClient is found, the request is allowed and only its id is cached; if none, the response is 400 Invalid Hostname and the miss is never cached.

The lookup, get_cached_llmclient_by_request(), resolves the request’s host and path to the LLMClient that serves them. For a chatbot’s own host, that means the account number in the hostname, then the chatbot whose RFC 1034 name is the first label. It caches only the LLMClient’s id, keyed by host and path, and reads the row fresh on every call, so fields such as deployed are never stale. Two more details keep it correct as chatbots come and go.

  • Saving or deleting an LLMClient invalidates the cached resolution of its hosts.
  • A host that resolves to no LLMClient is never cached. A chatbot created a moment ago is served on its first request, and nobody can fill the cache by sending made-up hosts.

Not caching misses would normally be a cost worth worrying about, because every unknown host means a database query. Here it isn’t, because layer 1 has already turned away hosts that have no Ingress.

Layer 4, because existing is not the same as deployed

The middleware asks one question. Is this a hostname that belongs to the platform? It accepts the host of any LLMClient that exists, deployed or not. Whether the chatbot is live is a separate question, and it’s answered where the request is handled. The chat API’s base view refuses anonymous prompts to an LLMClient that isn’t deployed.

if not is_authenticated_request(request) and not self.llmclient.deployed:
    # anonymous clients may prompt deployed LLMClients only. Their prompts run, and are
    # charged, as the LLMClient's owner. See dispatch()'s first lines.
    return self.error_response(request, PromptChatApiErrorNotFound("LLMClient not found."))

The source is smarter/apps/llmclient/api/v1/views/base.py, trimmed. The chat configuration view applies a similar rule. Splitting the two questions is deliberate. The owner of an undeployed chatbot can still test it with their own credentials, and an anonymous visitor gets a 404. Undeploying a chatbot also removes its DNS record, so in normal use its hostname stops resolving long before any of this is needed.

How much does it protect?

Host header validation is one of the checks that’s cheap to get right and expensive to get wrong, because the damage lands on other people, such as the user who clicks a poisoned reset link, the visitor served a poisoned cache entry. I’d rate validating the Host header at all at 7 out of 10. The dynamic narrowing in layer 3, on top of Traefik’s exact-host routing and Django’s static list, adds less than that on its own. It turns “any subdomain of the API domain” into “a subdomain that is a real chatbot”, which matters most for code inside the platform that builds URLs from the host, and for the day someone adds a wildcard route to the ingress controller without thinking about it.

Cost, burden, UX and stability

  • Cost. One cache lookup per request on a chatbot host, plus one primary-key read. No new infrastructure.
  • Admin burden is almost none. The host list follows the database, so there’s nothing to update when a chatbot is created, renamed or deleted. The middleware has a runtime switch (enable_middleware_security, on by default), which falls back to Django’s static list when it’s off. It’s a debugging aid, and it should stay on in production.
  • UX. A request that reaches Django with a host that isn’t a chatbot’s gets a plain 400 that says the hostname is invalid, rather than a Django error page or a 500.
  • Stability. Health probes and certificate challenges are exempt, so neither Kubernetes nor cert-manager can be locked out by a bug in the lookup. Because negative results aren’t cached, a new chatbot never waits for a stale cache entry to expire.

Takeaways

  • Never ship ALLOWED_HOSTS = ["*"]. If your hosts are dynamic, check them dynamically.
  • Keep Django’s static list as the outer wall, with your own domains and at most one leading-dot entry for the tenant subdomains.
  • Narrow that wildcard in middleware, against the same database that creates the hosts.
  • Cache hits by id and read the row fresh; don’t cache misses; invalidate when the resource is saved or deleted.
  • Route exact hosts at the ingress controller, so unknown hosts never cost you a database query.
  • Keep “is this one of our hosts?” separate from “is this chatbot live?”. They have different owners and different answers.

Closing thoughts

ALLOWED_HOSTS was designed for a site with a handful of names, and a platform that mints names for its tenants doesn’t fit it. The answer isn’t to switch the check off. It’s to let each layer do what it’s good at. The ingress controller routes only hosts that exist, Django’s static list admits only the platform’s own domains, a small middleware checks the rest against the database, and the views decide what each chatbot will actually answer.