Django CORS and CSRF per Chatbot: Each LLM Names the Web Pages That May Call It
Smarter extends django-cors-headers and Django’s CSRF middleware so that every deployed chatbot carries its own list of allowed browser origins, declared in its YAML manifest, instead of sharing one platform-wide list. This post explains what that list protects, what it doesn’t, how it is wired into Django and Traefik, and what it costs to run.
The setting is a chat widget. Smarter Chat, the @smarter.sh/ui-chat React component, can be dropped into any web page, such as a WordPress site, a Shopify store or a static HTML page. Once loaded, it calls two endpoints on its chatbot’s host, /config/ and /prompt/, from the visitor’s browser. That is a cross-origin request, from https://www.example.com to a host on the Smarter platform, so the browser asks the server first whether the page’s origin is allowed to read the answer. A Smarter platform hosts many chatbots, owned by many accounts, each embedded on different sites. Someone has to answer that question for each of them.
What CORS protects, and what it doesn’t
CORS is easy to overrate. It is a set of response headers that tell a browser which other origins may read a response. It doesn’t stop anyone from sending a request. curl ignores it, as does any server-side script. A Smarter chatbot that has no api key attached is public by design. Anyone who can reach its host can chat with it, and the CORS headers don’t change that.
What CORS does stop is a third-party page reading responses that the browser fetched on the visitor’s behalf. Without it, any page the visitor opens could call a chatbot’s apis from inside their browser, with whatever the browser attaches, and read the result. With a list that is too broad, every customer’s site could read every other customer’s chatbot in a visitor’s browser. With a list that is too narrow, the widget breaks with a “blocked by CORS policy” error and nothing else to go on.
So there are two ways to get it wrong, and a single global list gets one of them wrong for somebody. I’d put the importance of getting it right at about 6 out of 10 for a platform like this. The surface is every embedded chat on every site, which is large, but the damage from a mistake is bounded, because a public chatbot has little to steal and authentication is a separate layer. It is the first thing a widget’s owner runs into, though, and a wrong answer in either direction is visible at once.
The list lives in the chatbot’s manifest
Every Smarter resource is declared in a YAML manifest. An LLMClient (Smarter’s name for a chatbot) lists the origins that may call it in spec.config.prompt.allowedOrigins.
spec:
config:
prompt:
allowedOrigins:
- https://www.example.com
- http://localhost:5173 # your development server
The owner of the chatbot maintains this list, with the same smarter apply that changes everything else about it. The platform operator doesn’t edit a settings file or restart anything when a customer adds a site.
The manifest model refuses anything that isn’t a plain origin. It lowercases each one, drops a trailing slash, and rejects paths and wildcards, so https://*.example.com is an error rather than a quiet invitation to every subdomain. From spec.py, trimmed.
@field_validator("allowedOrigins")
@classmethod
def validate_allowed_origins(cls, value: Optional[List[str]]) -> List[str]:
origins: List[str] = []
for origin in value or []:
normalized = str(origin).strip().lower().rstrip("/")
if not ORIGIN_PATTERN.match(normalized):
raise ValueError(
f"allowedOrigins: {origin!r} is not an origin. Use a scheme, a host and an optional port, "
"e.g. 'https://www.example.com', without a path or a wildcard."
)
if normalized not in origins:
origins.append(normalized)
return origins
Rejecting wildcards is a deliberate limit. An owner with ten storefronts lists ten origins. That is more typing, and it means every origin that can read the chatbot in a browser is written down somewhere a person can review it.
How Django applies it
django-cors-headers has an extension point for exactly this, the check_request_enabled signal. When a request’s origin isn’t in the platform’s static settings (CORS_ALLOWED_ORIGINS, CORS_ALLOWED_ORIGIN_REGEXES, which cover the platform’s own hosts), the middleware sends the signal, and any receiver that returns True allows the origin, for the preflight and for the request itself. Smarter’s receiver is in smarter/apps/llmclient/cors.py.
@receiver(check_request_enabled, dispatch_uid="smarter.apps.llmclient.cors.allow_llmclient_origins")
def allow_llmclient_origins(sender, request: HttpRequest, **kwargs) -> bool:
origin = request.headers.get("Origin")
if not origin:
return False
llmclient = llmclient_for_cors(request)
allowed = origin_allowed(origin, llmclient)
return allowed
def origin_allowed(origin: Optional[str], llmclient: Optional[LLMClient]) -> bool:
if not origin or not llmclient:
return False
allowed = llmclient.allowed_origins or []
return origin.strip().lower().rstrip("/") in allowed
Three things in it carry the security weight.
First, it is scoped. llmclient_for_cors() returns a chatbot only for the endpoints that Smarter Chat calls, which are /, /config/ and /prompt/ on a deployed chatbot’s own host (or its custom domain), and the matching /api/v1/llm-clients/<id>/config/ and /prompt/ paths on the platform api. An origin that a chatbot’s owner allows never gains access to the web console, the manifest api, or another chatbot. One account’s list can’t widen another account’s exposure.
Second, it is an exact match. The comparison is set membership on normalized strings, with no regular expressions built from user input and no suffix matching.
Third, it fails closed. Every lookup inside llmclient_for_cors() is wrapped so that a request it can’t resolve, such as an unknown id, an undeployed host or a malformed path, returns None, and origin_allowed() answers False. The worst case of a bug in the lookup is a widget that doesn’t load, not an origin that is allowed by accident.

Middleware order decides what the browser reports
A detail that matters more for usability than for security is that SmarterCorsMiddleware sits second in Django’s middleware list, right after django-hosts resolves the host. From settings/base.py.
MIDDLEWARE = [
"django_hosts.middleware.HostsRequestMiddleware",
#
# handles cors, for the platform and for deployed llmclients. It is near the top so that every
# response, including the errors that later middleware returns, e.g. a 401 for an invalid api
# key or a 500, gets its CORS headers. Otherwise the browser reports a CORS error in place of
# the real one. Its llmclient lookup doesn't need request.user.
# -------------------------------
"smarter.lib.django.middleware.cors.SmarterCorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
# ...
]
If the api key middleware rejects a request before the CORS middleware has run, the 401 goes out without an Access-Control-Allow-Origin header, and the browser hides it behind a generic CORS error. A developer then debugs the wrong problem. Placed early, the real status reaches the page. The commit that moved it, 8105d9e00, added a test that a 401 from the token middleware carries the chatbot’s allowed origin.

Why Traefik stays out of it
The platform’s own Ingresses, which Terraform creates, use a Traefik CORS middleware with a fixed list of origins. A chatbot’s Ingress, which Smarter creates when the chatbot is deployed, deliberately doesn’t. From ingress.yaml.tpl.
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
# No Traefik cors middleware: it answers CORS preflight requests itself, with a fixed list of
# origins and headers, so Django, whose CORS middleware applies the platform's settings and each
# LLMClient's spec.config.allowedOrigins (smarter.apps.llmclient.cors), would never see them.
traefik.ingress.kubernetes.io/router.middlewares: ${environment_namespace}-https-redirect@kubernetescrd
The general lesson is that if a reverse proxy answers CORS preflights, the application behind it never gets a vote. A per-tenant policy has to live in one place, and it has to be the place that knows the tenants.
CSRF is a different question
CORS decides who may read a response in a browser. CSRF protection decides whether a state-changing request that arrives with the visitor’s cookies was meant by the visitor. The two get confused because both involve origins.
Smarter keeps Django’s model for the parts of the platform that a browser session authenticates, which are the web console, its forms and the React apps it hosts. Those requests need a CSRF token, and Django checks the Origin header against CSRF_TRUSTED_ORIGINS. The chat apis that a widget on someone else’s site calls are CSRF-exempt, which is the right call. A page on www.example.com can’t obtain a token for the platform’s domain, and those endpoints authenticate with an api key in a header, or not at all for a public chatbot. What keeps a forged request from another site from borrowing a visitor’s login is the cookie itself. As Smarter’s embedding guide points out, the console’s session cookie is SameSite=Lax, so browsers don’t attach it to requests from other sites.

What a chatbot’s own host does need is for Django’s origin check to accept that host, because every deployed chatbot has a host name of its own, and the list changes whenever someone deploys one. SmarterCsrfViewMiddleware builds the trusted origins for each request instead of keeping a global list up to date. From csrf.py.
def get_dynamic_trusted_origins(self, request: ASGIRequest) -> list[str]:
"""
Return request-scoped trusted origins.
IMPORTANT:
- never mutate Django settings
- always return a fresh list
"""
origins = list(settings.CSRF_TRUSTED_ORIGINS)
try:
if self.is_llmclient or self.is_config:
origin = request.build_absolute_uri("/").rstrip("/")
if origin not in origins:
origins.append(origin)
except Exception as exc:
logging.exception("%s failed building dynamic CSRF origins: %s", self.formatted_class_name, exc)
return origins
The list is the configured one plus, for a chatbot’s own endpoints, the request’s own origin. Nothing outside the request is added, and settings.CSRF_TRUSTED_ORIGINS is never written to, so one request’s chatbot can’t leak into another request’s check. The validation itself is still Django’s CsrfViewMiddleware.process_view(). Smarter changes only the list it checks against.
Both middlewares can be turned off at runtime with django-waffle switches, and a third switch can suppress CSRF for chatbot endpoints. The two enabling switches default to on and the suppressing one to off. Runtime switches are useful when a production incident needs a fast lever, and they are also a security control that an administrator can turn off without a deploy. If you copy this pattern, alert on changes to those switches.
Burden, cost, UX and stability
Admin burden. Close to none for the operator. The platform-wide regular expressions cover the platform’s own hosts and change only when the domain layout does. Each chatbot owner maintains their own list, in the manifest they already maintain.
Cost. No infrastructure. The allowed origins are a JSON column on the LLMClient row, and the lookup is a cached query by host and path, or by primary key, so a preflight usually costs a cache read.
UX. The common failure, a missing origin, shows up as “blocked by CORS policy” in the browser console. The embedding guide’s troubleshooting table lists it first, along with the exact-match rule that the scheme and port must match. Because the CORS middleware runs early, the other failures (a wrong api key, an undeployed chatbot) show their real status instead of another CORS error.
Stability. The receiver never raises, so a database hiccup during a preflight blocks one widget instead of returning a 500. Keeping Traefik out of chatbot preflights removes a second source of truth that could disagree with Django.
Should you do this?
If your platform serves many tenants whose pages call it from a browser, yes. Keep the platform’s own origins in settings, put each tenant’s origins on the tenant’s own record, apply them through django-cors-headers’ signal, and scope them to the few endpoints that the embedded client calls. Refuse wildcards. Make the lookup fail closed. Then remember that CORS is a rule for browsers. It sits alongside authentication, budgets and rate limits. It doesn’t replace any of them.
Takeaways
- Put per-tenant CORS origins on the tenant’s resource, not in settings, and let the owner maintain them.
- Use django-cors-headers’
check_request_enabledsignal rather than replacing its middleware. - Scope a tenant’s origins to the endpoints its embedded client calls, and match them exactly.
- Make the origin lookup fail closed, and never let it raise.
- Run the CORS middleware early, so error responses keep their CORS headers.
- Let one layer answer preflights. If the proxy answers them, the application can’t apply per-tenant rules.
- Keep CSRF and CORS separate in your head. Token-authenticated apis that third-party pages call can be CSRF-exempt, and cookie-authenticated pages can’t.
Closing thoughts
Per-chatbot CORS is a small amount of code, made of a validator, a signal receiver, a comment in an Ingress template, and a middleware that builds a list per request. What it buys is that the answer to “which pages may use this chatbot?” lives with the chatbot, is written down, is reviewed by its owner, and can’t spill over to anyone else’s.
