Django Chat Sessions Across Subdomains: Why a Smarter Session Key Opens Exactly One Chatbot

In Smarter, a Django platform for hosting LLM chatbots, every chat session is bound to the one chatbot that created it, and the server checks that binding on every request. A session key that belongs to a different chatbot is refused, and a fresh session starts. This post covers why that check is needed on a platform where every chatbot lives on a subdomain of the same parent, how it works, and what it doesn’t protect.

Many chatbots, one parent domain

Every deployed chatbot (an LLMClient, in Smarter’s terms) gets its own host name. The default is <name>.<account-number>.<api-domain>, and in a production layout the api domain is a subdomain of the platform’s own domain, as in api.platform.example.com. A platform with a thousand chatbots has a thousand hosts, all under one parent. That is good for isolation at the network edge, because each host has its own DNS record, its own TLS certificate and its own Kubernetes Ingress.

Browsers, though, think of all of those hosts as one site. A cookie set with Domain=<platform-domain> is sent to every one of them. SameSite rules don’t help either, because those compare registrable domains, and every chatbot shares the platform’s. From the browser’s point of view, a request from one chatbot’s page to another chatbot’s api is a same-site request.

A visitor's browser holds a session_key cookie set with Domain=platform.example.com, and sends it to every host under that parent, such as chatbot-a.<account-1>.api.platform.example.com, chatbot-b.<account-2>.api.platform.example.com, and one host for every deployed chatbot, each with its own DNS record, TLS certificate and Ingress. SameSite doesn't help, so a request from chatbot A's page to chatbot B's api is same-site.

A chat session in Smarter is a row in the Prompt table, identified by a 64-character hex session_key. The client gets a key from the chatbot’s /config/ endpoint and sends it back with each prompt, so that the chatbot continues the same conversation. The key can arrive in four places, and the server looks in them in order, first the url, then the JSON body, a cookie, and a query parameter. Any of those can carry a key that was minted for a different chatbot, such as a cookie on the parent domain, a url that someone copied from one chatbot’s page to another, or a client that keeps one key per browser instead of one per chatbot.

What goes wrong without the check

If the server takes a well-formed key at face value, chatbot B continues chatbot A’s conversation. That is worse than a confused assistant.

  • A’s history, which may contain whatever the visitor told A, becomes part of the context that B sends to B’s LLM provider.
  • B’s owner may be a different account from A’s. The conversation crosses a tenant boundary, into another account’s logs, budgets and provider bills.
  • B’s system prompt, plugins and tools act on A’s conversation, which is input that no one designed them for.

So the question “which chatbot does this session belong to?” is a tenant-isolation question, not a convenience one. I’d rate it 8 out of 10 for a multi-tenant chatbot platform, because every deployed chatbot shares the parent domain, so the surface is the whole platform, and the failure moves one tenant’s conversation into another’s.

The check

The rule lives in one place, PromptHelper.get_cached_chat(), the method that turns a session key into a Prompt. It loads the session by key, compares the session’s chatbot with the chatbot that the request is for, and on a mismatch discards the session and generates a new key. From prompt_helper.py, trimmed.

prompt: Optional[Prompt] = None
if self.session_key:
    try:
        prompt = get_cached_prompt_by_session_key(self.session_key)
    except Prompt.DoesNotExist:
        pass

if prompt and self.llmclient and prompt.llmclient_id != self.llmclient.id:
    # A session belongs to one llmclient. A key of another llmclient's session, e.g. a
    # session_key cookie that the browser sends to every host of the platform's domain,
    # starts a new session rather than continuing the other llmclient's chat.
    prompt = None
    self._session_key = self.generate_session_key()

if not prompt:
    prompt = Prompt.objects.create(
        session_key=self.session_key,
        user_profile=self.user_profile,
        llmclient=self.llmclient,
        ip_address=self.ip_address,
        user_agent=self.user_agent,
        # ...
    )

Three details make it work end to end.

The other chatbot’s session is left alone. The refused Prompt isn’t modified or deleted. Its owner’s chatbot still has it, and the visitor can still continue it on the right host.

The new key goes back to the client. The chat api view adopts the helper’s key after building it, so the response names the session that was actually used, not the one that was refused. From the llmclient api base view.

self._chat_helper = PromptHelper(
    request=self.smarter_request, session_key=self.session_key, llmclient=self.llmclient
)
# PromptHelper starts a new session when the key belongs to another llmclient's session.
self._session_key = self._chat_helper.session_key

Without that line, the client would keep sending the refused key, and every prompt would start yet another session.

The check doesn’t depend on where the key came from. Smarter Chat, the embeddable React client, sets its own session cookie without a Domain attribute and, by default, for the page’s path, so a browser keeps a separate key for each chatbot’s page. That is a good default, but the server doesn’t rely on it. A key from a url, a body or any cookie goes through the same comparison.

Flow of the session check. A key is taken from the url, the JSON body, a cookie or a query parameter, in that order, and accepted_session_key() skips malformed ones. The Prompt is loaded by its session_key; if there is no valid key or no such session, or the Prompt's llmclient_id is not this chatbot's, a new session starts and the other chatbot's Prompt is left unchanged. If it matches, the conversation continues. The api view returns the key actually used.

Malformed keys start a session instead of failing

The same change made bad input harmless. A cookie named session_key that some other application set on a parent domain, or a truncated key pasted into a url, used to raise inside the middleware and return a 500. Now each candidate goes through accepted_session_key(), which validates its format and ignores it if it is malformed, so the server tries the next source, and then starts a new session. From request.py, trimmed.

def accepted_session_key(self, session_key: Optional[str], source: str) -> Optional[str]:
    session_key = session_key.strip().rstrip("/") if isinstance(session_key, str) else None
    if not session_key:
        return None
    if not SmarterValidator.is_valid_session_key(session_key):
        logger.warning(
            "%s.accepted_session_key() - ignoring a malformed session key from the %s of the request.",
            self.srm_formatted_class_name,
            source,
        )
        return None
    return session_key

A 500 on bad input is a stability problem, and it is also a small security problem, because it fills the error logs, it hides real errors, and it tells a prober which inputs reach deep code. Treating client-supplied identifiers as untrusted, and making the failure path “start fresh”, removes all three.

What the check doesn’t do

The binding is between a session and a chatbot, not between a session and a person. A session key is a bearer value, like a session cookie. Whoever holds a valid key for a chatbot can continue that conversation on that chatbot, and the chatbot’s /config/ endpoint returns the session’s history so that a returning visitor sees their chat. That is how anonymous chat on a public chatbot works, since there is no login to bind it to. It also means the key deserves the care a session cookie gets. Keep it out of places it can leak from, such as shared links, analytics, referrers and logs.

For a chatbot that requires authentication (one with an api key attached), a request without valid credentials is refused, whatever session key it carries. The api key decides who may talk to the chatbot. The session key only decides which conversation continues.

Burden, cost, UX and stability

Admin burden. None. The check is code, with no settings and no per-chatbot configuration.

Cost. One integer comparison on a row that the request loads anyway. When a key is refused, one extra Prompt row is created, which would have been created for a new visitor in any case.

UX. A visitor who moves from one chatbot to another starts a fresh conversation on the second one, which is what they expect. Their conversation with the first chatbot is still there when they go back.

Stability. Better. Malformed keys are a warning and a new session, not a 500, and the client is told which session it is in, so it stops resending a key that will be refused.

The behavior is pinned by tests in the commit that introduced it, 8105d9e00. A malformed session_key cookie starts a new session, another chatbot’s key starts a new session and leaves that chatbot’s Prompt unchanged, and the same chatbot’s key continues its session.

Should you do this?

Yes, if your tenants share a parent domain, and probably even if they don’t. The general rule is that any identifier that the client sends is a claim, and the server checks the claim against the context of the request before acting on it. Here the context is the chatbot that the host or url resolves to. In your system it might be the tenant from the subdomain, the account from the api key, or the user from the session. Scoping cookies tightly (no Domain attribute, a narrow Path) is a good second layer, but cookies aren’t the only way a key travels, so the server-side check is the one to rely on.

Takeaways

  • On a platform where tenants are subdomains of one parent, assume that cookies and keys will cross between tenants, and check ownership on the server.
  • Bind each session to the resource that created it, and compare on every lookup, in one shared method.
  • On a mismatch, start a new session, leave the other one untouched, and return the new key to the client.
  • Treat malformed client identifiers as absent, not as errors.
  • Remember that a session key is a bearer credential, and handle it like one.
  • Scope your own cookies to the host and path that need them, as a second layer.

Closing thoughts

The fix is a dozen lines, and most of what it took was noticing that a chat session’s owner is part of its identity. Once that is written down in the one method that loads a session, every endpoint that continues a chat gets it, and no chatbot can pick up a conversation that it didn’t start.