Why Your Embedded Widget's API Calls Get 403'd (And It's Not CORS)
The setup: a chat widget script loaded from widget.wizz.subhranshu.com, embedded on subhranshu.com via a <script> tag. Click the bubble, an iframe opens pointing at widget.wizz.subhranshu.com/widget?apiKey=..., and the React UI inside that iframe calls api.wizz.subhranshu.com/chat/stream directly with fetch. The tenant had already configured subhranshu.com as an allowed domain for their widget. Every request still came back 403 Domain 'widget.wizz.subhranshu.com' is not allowed.
Note what's in that error: the rejected domain is the widget's own host, not the page it's embedded on. That's the whole bug, and it's not a CORS bug at all.
Two layers, not one
It's easy to assume "cross-origin request failed" means CORS. Here it doesn't - the backend's CORSMiddleware is about as permissive as it gets:
# backend/app/main.py app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], allow_credentials=True, )
Any origin can read the response. The preflight OPTIONS request succeeds, the browser is satisfied, and the actual POST /chat/stream goes out. The 403 comes back from inside the route handler, from an application-level dependency:
# backend/app/dependencies.py def get_widget_config( raw_request: Request, tenant_id: Annotated[str, Depends(get_current_tenant_id)], ) -> dict[str, Any]: config = ( supabase.schema(settings.SUPABASE_SCHEMA) .table("widget_config") .select("*") .eq("tenant_id", tenant_id) .single() .execute() ) ... allowed_domains = widget_config.get("allowed_domains", []) if allowed_domains: origin = raw_request.headers.get("origin", "") origin_host = ( origin.replace("https://", "").replace("http://", "").split(":")[0].lower() ) allowed_domains = [domain.lower() for domain in allowed_domains] if origin_host not in allowed_domains: raise HTTPException( status_code=403, detail=f"Domain '{origin_host}' is not allowed.", )
allowed_domains is set by the tenant themselves, per widget, via PUT /config (backend/app/routers/config.py) - it's their answer to "where am I embedding my chatbot." CORS and this allowlist are answering two completely different questions that both happen to stare at the Origin header:
| Layer | Question it answers | Configured by |
|---|---|---|
CORSMiddleware | Can any browser JS read this response at all? | The backend operator, once, globally |
allowed_domains check | Is this specific tenant's widget allowed to run on this specific domain? | Each tenant, per widget |
Wide-open CORS is intentional here - the API needs to be callable from arbitrary customer domains, since every tenant embeds the widget on a different site. The allowlist is the layer that actually enforces per-tenant boundaries: without it, anyone with a stolen API key, or anyone who simply points an iframe at someone else's widget config, could run tenant A's chatbot (and burn tenant A's LLM budget) from a domain tenant A never approved. Nothing about CORS prevents that; CORS doesn't know tenants exist.
The bug wasn't that this second layer existed. It's that it was checking the wrong signal.
The Origin header can't say what you want it to say
The tenant's allowed_domains list said subhranshu.com. The Origin header on the actual request said widget.wizz.subhranshu.com. Both were "correct" - they were just answering different questions, because of how browsers assign origins to different kinds of embedded content.
A <script src="https://widget.wizz.subhranshu.com/embed.js"> tag fetches its bytes from a different origin, but once that JavaScript runs, it executes inside the including page's document. window.location.origin inside that script genuinely is https://subhranshu.com, because the script has no document of its own - it's just code running in the parent's realm.
An <iframe src="https://widget.wizz.subhranshu.com/widget"> is nothing like that. It creates an entirely separate browsing context: its own window, its own document, its own origin, its own everything. Code running inside that iframe has no more access to window.location.origin of the parent page than a completely unrelated tab would. So when the iframe's own JS calls fetch(), the browser stamps the Origin header with the iframe's own origin - https://widget.wizz.subhranshu.com - because that's the actual origin of the document that issued the request. It's not lying, and it's not a bug. The browser has no way to know, and no reason to care, what page happens to have this iframe embedded in it.
| What's running | Where it executes | window.location.origin |
|---|---|---|
| The host page itself | subhranshu.com document | https://subhranshu.com |
The loader script (ui.ts), fetched from widget.wizz.subhranshu.com | Inside the subhranshu.com document | https://subhranshu.com |
| The chat UI inside the iframe | Its own separate document | https://widget.wizz.subhranshu.com |
This is the crux: the only piece of code in this whole chain that ever actually observes the parent page's real origin is the loader script, because it's the only piece that runs in the parent's execution context rather than in a document of its own. The iframe can't get that value from the platform - Origin, document.referrer (unreliable, stripped by referrer policies, and not meant for this), none of it - because iframes are designed to not automatically know or trust their embedding context. That's a deliberate part of the browser security model, not an oversight.
Referer looks tempting as a shortcut here, but it's the wrong tool for the same reason: it can be absent (referrer-policy, HTTPS→HTTP navigations, browser privacy settings) and was never meant to be a trustworthy identity signal. If you need the parent's origin, someone who actually runs in the parent's context has to hand it to you.
The fix: capture it where it's actually knowable, thread it through explicitly
The loader script is the one place that legitimately knows the real parent origin, so that's where the fix starts:
// widget/src/ui.ts const iframeUrl = new URL(`${WIDGET_APP_URL}/widget`); iframeUrl.searchParams.set("apiKey", apiKey); iframeUrl.searchParams.set("botName", config.bot_name); // ... iframeUrl.searchParams.set("parentOrigin", window.location.origin); const iframe = document.createElement("iframe"); iframe.src = iframeUrl.toString();
window.location.origin here is https://subhranshu.com, correctly, because - as above - this script executes in the parent document, not a document of its own. That value rides into the iframe as a query parameter, gets read on the widget page's server-rendered route, and is passed down as a prop:
// admin/src/app/widget/page.tsx const { apiKey, /* ... */, parentOrigin, mode } = await searchParams return ( <WidgetView apiKey={apiKey ?? ""} backendUrl={BACKEND_URL} parentOrigin={parentOrigin} /* ... */ /> )
From there it lands in the chat hook, which sets it as a custom header on the actual API call instead of relying on Origin:
// admin/src/views/widget/hooks/useStreamingChat.ts const response = await fetch(`${backendUrl}/chat/stream`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, ...(parentOrigin ? { "X-Widget-Parent-Origin": parentOrigin } : {}), }, body: JSON.stringify({ messages: [...history, { role: "user", content: question }], }), });
And the backend now prefers that header over the standard one when deciding what to check against the allowlist:
# backend/app/dependencies.py origin = raw_request.headers.get( "x-widget-parent-origin" ) or raw_request.headers.get("origin", "")
X-Widget-Parent-Origin wins when present; Origin remains the fallback for anything hitting the API directly rather than through the iframe. The allowlist check itself doesn't change at all - it's still comparing a hostname against allowed_domains - it's just finally being handed the value the tenant actually meant when they typed subhranshu.com into their settings.
One caveat worth stating plainly: X-Widget-Parent-Origin is a self-reported header, not a cryptographic proof of anything. Anyone with a valid API key can set it to whatever they want with a raw curl request - it's config-correctness bookkeeping for the legitimate embed flow, not an additional security boundary. The actual security boundary here is still the API key on the Authorization header. This allowlist exists to stop misconfiguration and casual misuse (tenant B's widget quietly working on tenant A's competitor's site), not to withstand a determined attacker who already has valid credentials - the same trust tier as something like a Stripe publishable-key domain restriction.
Wrap-up
The general shape of this bug shows up anywhere you build an iframe-embedded widget - chat bubbles, ad units, payment widgets, feedback forms, anything with the pattern "script tag drops in an iframe, iframe talks to an API." The Origin header your API sees on that call will always be the embedded document's own origin, never the page it's sitting inside of, because that's what an origin fundamentally means for a browsing context - and no amount of correct-looking allowlist configuration changes what the browser is capable of reporting. If you need to gate behavior on the parent page's identity, you cannot read it off the request the iframe makes; you have to capture it at the one point in the chain that actually runs inside the parent's execution context - typically the loader script itself - and carry it forward explicitly, whether that's a URL parameter and custom header like here, or a postMessage handshake between iframe and parent for cases where the value is needed after the iframe has already loaded. Either way, the fix is the same idea: stop asking the platform for information it was never going to give you, and get it from the one place that actually has it.