MCP App Iframe Sandbox: Origins, CSP, postMessage, and CORS

MCP App resources run inside host-controlled iframes with their own origin, CSP, and bridge rules.
MCP App iframe bugs are easy to misread because they look like normal frontend bugs.
The resource is blank, so you check React. The image is missing, so you check the URL. The API call fails, so you check the endpoint. Sometimes that is enough. But in MCP Apps, ChatGPT Apps, and interactive Claude Connectors, the iframe boundary is usually part of the bug.
The host controls where the resource runs, what origin it gets, what domains it can contact, which browser permissions it can ask for, and how the iframe talks back to the conversation.
TL;DR: Treat an MCP App resource as a sandboxed web app with a narrow bridge to the host. It cannot read host cookies, host localStorage, or the parent DOM. It can only load assets and call APIs allowed by resource CSP, and API servers still need CORS. Use standard MCP App bridge APIs or framework hooks for host communication, keep auth and durable state server-side, and debug iframe failures by checking protocol metadata, browser console errors, CSP violations, and CORS headers in that order.
Why This Is the Search Gap
Most MCP App content explains the happy path: define a tool, return structuredContent, link a ui:// resource, render React.
The next wave of developer searches is messier:
- “MCP App iframe sandbox”
- “ChatGPT App CORS error”
- “MCP App postMessage bridge”
- “Claude Connector blank iframe”
- “MCP App resource origin”
- “openai widgetDomain cookies”
- “MCP App CSP connectDomains not working”
- “ChatGPT App OAuth redirect iframe”
Those searches all point at the same thing: the app is now real enough that browser security matters.
This post ties the pieces together. The dedicated CSP guide covers domain lists. The resource metadata guide covers _meta.ui. This article is about how the iframe boundary changes your app architecture.
The Mental Model
An MCP App has four security actors:
| Actor | What it controls | What it should not receive |
|---|---|---|
| AI host | Conversation UI, model calls, iframe creation, bridge messages | Your private API credentials |
| MCP server | Tools, auth, data access, resource registration | Host page DOM access |
| Resource iframe | Interactive UI, user input, visual state | Host cookies or durable secrets |
| External APIs | Business data and side effects | Browser requests from unknown origins unless allowed |
The iframe is intentionally boxed in. That box is what lets a host render third-party UI inside a conversation without handing the app the keys to the host product.
For developers, the main rule is this:
Build the iframe like untrusted UI, and build the server like the trusted boundary.
That does not mean the iframe is hostile. It means the iframe can be reloaded, moved across origins, constrained by CSP, denied permissions, and disconnected from browser storage. Put the work that must be reliable on the server side.
What the Iframe Cannot Do
An MCP App resource cannot:
- read the host page DOM
- read ChatGPT or Claude cookies
- read the host page localStorage or sessionStorage
- directly call privileged host functions outside the MCP Apps bridge
- assume top-level navigation works
- assume popups, downloads, camera, microphone, clipboard, or geolocation are available
- assume the iframe origin is the same as your MCP server origin
That is good. If a dashboard resource could read the parent host DOM, every MCP App would be a browser extension with conversation access. The sandbox stops that.
The cost is that app code must be explicit about data flow. If the UI needs data, it should come from a tool result, _meta, app state, or a server tool call. If the UI needs to do something privileged, it should ask the host through the bridge or call the MCP server.
The Three Origin Problem
Most production MCP Apps involve at least three origins:
| Origin | Example | Job |
|---|---|---|
| Host origin | https://chatgpt.com or a Claude host origin | Renders the conversation and iframe |
| Resource origin | https://widgets.example.com | Serves the app HTML, JS, CSS, and assets |
| Server/API origin | https://api.example.com or your MCP endpoint | Handles tools, auth, database reads, and writes |
In a local setup, these might all be localhost ports. In production, separate them on purpose when you can.
A dedicated resource origin gives you cleaner isolation:
- resource cookies do not mix with your main app
- CSP can be narrower
- cache policy can match static assets
- host compatibility fields can point at a stable widget domain
- security review is easier because the app iframe has a clear home
Do not assume the resource origin can freely call the server origin. Browser rules still apply. If the iframe calls https://api.example.com, the resource CSP must allow it, and https://api.example.com must accept the iframe origin through CORS.
CSP Decides Whether the Request Can Leave
Resource CSP is declared in resource metadata. In the standard MCP Apps shape, it lives under _meta.ui.csp.
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
description: 'Show a billing dashboard',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
},
domain: 'https://widgets.example.com',
},
},
};
Use:
connectDomainsforfetch,XMLHttpRequest, and WebSocketsresourceDomainsfor images, scripts, styles, fonts, media, and similar assetsframeDomainsfor nested iframes
If a domain is missing, the browser can block the request before the server ever sees it. Your API logs will show nothing because no request arrived.
That is why “my API is down” and “my CSP is wrong” can look the same from React. Open the iframe console. A CSP violation usually says which directive blocked the request.
CORS Decides Whether the API Accepts It
CSP is only half of client-side fetch.
If the resource iframe at https://widgets.example.com calls https://api.example.com/orders, the browser asks whether https://api.example.com allows that origin. The API answers with CORS headers.
A simple production rule:
Access-Control-Allow-Origin: https://widgets.example.com
Access-Control-Allow-Credentials: true
Only include credentials when you actually need cookies or HTTP auth for that API. Many MCP Apps should avoid browser credentials entirely and call server-side tools instead.
For preflighted requests, the API also needs to answer OPTIONS with allowed methods and headers:
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Do not use Access-Control-Allow-Origin: * with credentials. Browsers reject that combination, and it is usually a sign the resource is calling an API from the wrong layer.
Prefer Server-Side Tool Calls for Private Data
Client-side fetch is fine for public assets, public APIs, and narrow same-session interactions.
For private data, server-side tools are usually better:
- The host calls your MCP tool.
- The MCP server checks auth and reads private data.
- The tool returns model-readable
content, UI-readablestructuredContent, and optional hidden_meta. - The iframe renders that data without holding tokens.
That path avoids exposing API tokens in the iframe and reduces CORS work. It also keeps the model, UI, and server in the same MCP contract.
If the UI needs a follow-up action, use the standard app action path or a framework hook such as useCallServerTool rather than inventing a browser API client with long-lived credentials.
import { useCallServerTool } from 'sunpeak';
export function ApproveButton({ invoiceId }: { invoiceId: string }) {
const callServerTool = useCallServerTool();
return (
<button
type="button"
onClick={() =>
callServerTool({
name: 'approve_invoice',
arguments: { invoiceId },
})
}
>
Approve
</button>
);
}
The server still validates the request. The iframe is a UI, not an authority.
postMessage Is an Implementation Detail, Not Your App API
The MCP Apps host bridge runs across the browser iframe boundary. Under that boundary, host-to-resource communication uses postMessage patterns, but application code should not depend on ad hoc parent-window messages.
Prefer:
- standard MCP Apps bridge methods
- host-provided SDK methods where the host requires them
- framework hooks such as
useToolData,useHostContext,useAppState,useCallServerTool,useSendMessage, anduseUpdateModelContext
Avoid:
- adding broad
window.addEventListener('message', ...)handlers that trust any origin - sending secrets through
postMessage - calling
window.parentdirectly for host behavior - assuming
window.openaiexists in every MCP App host
If you do add low-level message handling for a host-specific feature, validate the message shape and source. Treat it like external input. The host bridge may be trusted as a transport, but your server tools still need normal validation when a UI action reaches backend code.
Cookies Are Usually the Wrong State Layer
The iframe may have storage for its own origin, but you should not build core app behavior around it.
Iframe storage can be:
- partitioned by the browser
- cleared between sessions
- scoped to a host-controlled origin
- different between web and desktop hosts
- blocked by privacy settings
- reset when you move to a dedicated resource domain
That makes cookies and localStorage poor homes for approvals, drafts, carts, auth tokens, or records that need auditability.
Use this split instead:
| Data | Better home |
|---|---|
| Auth token | MCP auth flow, server session, or server-side token store |
| Draft before submit | React state, then app state or server draft |
| Selected rows the model should know | useAppState or the standard host bridge |
| Durable settings | Database through a server tool |
| Public image cache | Resource origin or CDN |
| Internal IDs for UI rendering | Tool result _meta, when the model should not see them |
If you use iframe cookies, keep them boring: short-lived, scoped, secure, and backed by a server fallback.
OAuth Redirects Should End at the Server
OAuth flows are a common source of iframe confusion.
The OAuth redirect URI should usually land on your server, not inside the resource iframe. The server validates the OAuth response, stores the token securely, and then lets future MCP tool calls use that connection.
The iframe can show account state, empty states, and “connect account” affordances, but it should not hold OAuth tokens. If it needs to start a connect flow, route through the host-supported auth path or your server’s connect endpoint.
For production connectors, this also keeps callback URLs and discovery metadata aligned with MCP auth expectations. The MCP App authentication guide covers that flow in more detail.
Blank Iframe Debugging Checklist
When an MCP App iframe is blank, debug in this order.
1. Check the tool-to-resource link
Confirm the tool points at the resource the host should render.
In standard MCP Apps metadata, that is usually _meta.ui.resourceUri on the tool. In ChatGPT compatibility code, you may also mirror that through _meta["openai/outputTemplate"].
If the URI is wrong, the host may call the tool but never render the UI.
2. Confirm the resource can be read
Use an MCP client, local inspector, or test fixture to read the resource directly. Confirm:
- the resource URI exists
- the MIME type is correct for an MCP App HTML resource
- the returned HTML includes the current build
- resource metadata includes the expected CSP, permissions, and domain fields
If the resource cannot be read locally, a real host will not fix it.
3. Open the iframe console
Look for:
- JavaScript exceptions
- missing bundle files
- CSP violations
- blocked CORS requests
- blocked images or fonts
- failed bridge initialization
- React hydration errors
The iframe console is the fastest way to separate a protocol bug from a frontend bug.
4. Test with a minimal tool result
Render the resource with the smallest valid structuredContent shape. Many blank screens come from assuming optional data exists.
If the minimal result works, add fields back until the UI breaks. Then make that state a simulation or E2E fixture.
5. Compare local inspector and real host behavior
A local inspector can prove your resource, metadata, tool result, and iframe behavior without a paid host session. Then the real host test checks integration details: account state, live auth, resource caching, host rollout state, and mobile behavior.
With sunpeak, use the local inspector while developing and add automated E2E coverage through the testing framework. That catches the iframe bugs that are painful to reproduce by manually refreshing a real host.
Production Checklist
Before shipping an MCP App resource, check these items:
- Tool metadata points at the correct
ui://resource. - Resource metadata includes only the CSP domains the iframe needs.
- API servers return CORS headers for the resource origin, not the host origin.
- Tokens stay on the server.
- The iframe does not depend on host cookies, host DOM, or parent localStorage.
- App actions call server tools through the bridge.
- OAuth redirects terminate at the server.
- Permission requests have denied-state UI.
- The app has tests for blank, loading, error, empty, unauthorized, and long-content states.
- The app has cross-host tests if it supports both ChatGPT Apps and Claude Connectors.
The iframe sandbox is not just a security detail. It is the shape of the platform. Once you build with that boundary in mind, MCP Apps become easier to reason about: tools own trusted work, resources own interaction, resource metadata tells the host how to run the iframe, and tests prove each layer before the app reaches a real conversation.
Start with the MCP App framework, inspect an existing server with npx sunpeak inspect --server <url-or-command>, or add automated coverage with the MCP testing framework. The faster you can reproduce iframe state locally, the less time you spend debugging blank cards in a live host.
Get Started
npx sunpeak newFurther Reading
- MCP App resource metadata - CSP, permissions, and widget fields
- MCP App CSP domains - connectDomains, resourceDomains, and frameDomains
- MCP App lifecycle and host bridge
- MCP App authentication - OAuth 2.1 and secure token handling
- Testing MCP App data flow - content, structuredContent, _meta, and host bridge state
- Security testing MCP Apps - CSP, auth, annotations, and input validation
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak resource metadata docs
- sunpeak CSP and CORS docs
- MCP Apps overview - official Model Context Protocol docs
- OpenAI Apps SDK reference
Frequently Asked Questions
Why do MCP Apps run in sandboxed iframes?
MCP Apps run in sandboxed iframes so an AI host can render interactive UI without giving that UI direct access to the host page, conversation DOM, account cookies, or privileged browser APIs. The iframe boundary lets the host control resource loading, permissions, origin isolation, and the postMessage bridge used for tool input, tool results, and app actions.
Can an MCP App iframe read ChatGPT or Claude cookies?
No. The MCP App resource runs on its own iframe origin, not the host application origin. It cannot read host cookies, localStorage, DOM state, or session data. If the app needs user data, fetch it through server-side MCP tools or through an authenticated API that the server controls.
Why does fetch fail inside my MCP App resource?
A client-side fetch can fail for two separate reasons. First, the resource CSP must allow the target origin through connectDomains. Second, the API server must allow the iframe origin through CORS. If either side is missing, the browser blocks the request before your app receives a useful response.
What is the safest origin setup for a production MCP App?
Use a dedicated HTTPS origin for app resources, keep the MCP server API on a separate server origin when possible, list only the domains the resource actually needs in CSP, and keep tokens on the server. For ChatGPT-specific apps, add the ChatGPT compatibility domain field only as a mirror of your standard MCP App resource metadata.
Should MCP App resources use cookies?
Avoid relying on iframe cookies for core app state. Browser storage can be partitioned, blocked, cleared, or scoped to a host-controlled resource origin. Use cookies only for low-risk, same-origin resource needs with a fallback. Store durable business state and auth sessions server-side.
How does postMessage work in MCP Apps?
The host and iframe communicate over a structured MCP Apps bridge, implemented with postMessage under the browser boundary. Your app should use the standard bridge or framework hooks instead of adding ad hoc parent-window listeners. Treat bridge payloads as protocol data, validate anything that reaches server tools, and feature-detect host-specific APIs.
How do I debug a blank MCP App iframe?
Start with the protocol contract: confirm the tool points at the right resource URI and the resource can be read. Then inspect the iframe console for JavaScript errors, CSP violations, blocked CORS requests, missing assets, and bridge initialization failures. Reproduce the same tool result in a local MCP App inspector before testing the real host.
Do Claude Connectors and ChatGPT Apps use the same iframe rules?
Both render MCP App resources inside host-controlled iframes, so the same base model applies: resource metadata, sandboxing, CSP, bridge messages, tool results, and server-side auth. Host-specific differences still exist in supported permissions, display modes, resource caching, and compatibility fields, so test both host runtimes when you support both.