Handling Cross-Origin Iframe Restrictions
A checkout page embeds a payment widget from pay.vendor.test, a help page embeds a chat bubble from a support vendor, a dashboard embeds a reporting tool from a sibling domain. In every case the embedded document belongs to a different origin than the page that hosts it, and the browser enforces a hard boundary between the two. Test code that treats the iframe as if it were part of the host document fails immediately and loudly; test code that goes through Playwright's own locator machinery crosses the boundary without noticing it exists. This page explains exactly where the line is drawn, which Playwright APIs sit on which side of it, and how to repair the three failures that actually block teams: framing refusals, missing third-party session state, and frames that detach mid-action.
Root cause: the same-origin policy applies to the page, not to the driver
The same-origin policy is enforced inside a renderer process: script running in app.example.test may not read the DOM, cookies, or storage of a document served from pay.vendor.test. Playwright, by contrast, issues commands over the browser's remote debugging protocol from a separate process, and Chromium's site isolation gives the embedded document its own renderer that the driver can address directly. So page.evaluate() inherits every restriction the page has, while frameLocator(), locator.contentFrame(), page.frames(), and page.route() all operate at a level where the origin of a frame is only a routing detail. This distinction sits underneath every technique in Iframes & Embedded Content, part of Reliable Selector Strategies for Playwright.
Minimal reproducible example
The test below performs the same read twice — once from inside the page and once through the driver — so the difference in behaviour is visible in a single run. The first form throws a browser SecurityError; the second fills a card field in a vendor-hosted document without any special configuration.
import { test, expect } from '@playwright/test';
test('cross-origin iframe: what is blocked and what is not', async ({ page }) => {
await page.goto('https://app.example.test/checkout');
// BLOCKED. page.evaluate serializes this function into the parent page's
// renderer, so it runs under the parent's origin. Touching contentDocument
// of a frame from another origin throws inside the browser:
// SecurityError: Blocked a frame with origin "https://app.example.test"
// from accessing a cross-origin frame.
const readFromPage = page.evaluate(() => {
const el = document.querySelector('iframe') as HTMLIFrameElement;
return el.contentDocument!.title; // never returns for a foreign origin
});
await expect(readFromPage).rejects.toThrow(/cross-origin/i);
// ALLOWED. frameLocator resolves the frame through the driver channel,
// outside the page, so the origin of the embedded document is irrelevant.
const card = page
.frameLocator('iframe[title="Card details"]') // lazy frame handle
.getByRole('textbox', { name: 'Card number' }); // role query inside it
await card.fill('4242424242424242');
await expect(card).toHaveValue('4242424242424242');
});
Note that frameLocator() never resolves eagerly. It records the path to the frame and re-resolves it on every action, which is what makes it survive the re-navigations that third-party widgets perform constantly.
One implementation detail is worth internalising because it explains most confusing symptoms. When Chromium loads a document from another site into a frame, site isolation moves that document into a separate renderer process — an out-of-process iframe. The parent renderer then holds only a placeholder for it and genuinely cannot see the child's DOM at any price, which is why no amount of injected script will ever recover the content. Playwright is attached to the browser rather than to a single renderer, so it addresses the child process directly and the split is invisible from your test. The practical consequence: an operation either runs in the page and is subject to isolation, or runs through the driver and is not, with no middle ground to engineer around.
Step-by-step fix
-
Reach the frame through the driver, never through page JavaScript. Replace every
contentDocument,contentWindow, andpostMessagescraping hack withpage.frameLocator(selector)or the newerpage.locator(selector).contentFrame(). Both return a lazy handle you chain role and text queries onto, exactly as you would onpage. If you already index frames by name or URL, prefer the locator form:page.frame({ url: /vendor/ })returnsnullthe instant the frame has not attached yet, whereas aFrameLocatorwaits. Chaining rules for deeper structures are covered in Automating Elements Inside Nested Iframes. -
Prove the frame exists before blaming the locator. A timeout message that ends in
waiting for locator('iframe').contentFrame()tells you nothing about whether the browser ever created the frame. Dump the frame tree and the navigation status first; the answer is usually visible in three lines of output.
import { test } from '@playwright/test';
test('report every frame the page owns', async ({ page }) => {
// Frames attach asynchronously; logging the event shows ordering problems.
page.on('frameattached', frame => console.log('attached →', frame.url()));
// Console messages from a refused frame surface on the parent page.
page.on('console', msg => console.log('console:', msg.text()));
const response = await page.goto('https://app.example.test/checkout');
console.log('top-level status', response?.status());
for (const frame of page.frames()) {
// An empty or about:blank URL means the document was never committed.
console.log(frame.name() || '(anonymous)', '→', frame.url());
}
});
- Remove the framing refusal at the network layer. If the vendor sends
X-Frame-Options: DENYor a Content-Security-Policy containingframe-ancestors 'none', the browser commits an empty document and printsRefused to display '…' in a frame because it set 'X-Frame-Options' to 'deny'. No locator can recover from that, because there is nothing in the frame to locate. In a test environment you control, intercept the frame's response and strip the offending headers withroute.fetch()plusroute.fulfill(), the pattern described in Intercepting and Modifying Network Requests.
import { test, expect } from '@playwright/test';
test('unblock a document that refuses to be embedded', async ({ page }) => {
await page.route('https://pay.vendor.test/**', async route => {
// Only rewrite the document itself; sub-resources pass through untouched.
if (route.request().resourceType() !== 'document') return route.continue();
const response = await route.fetch(); // real upstream response
const headers = { ...response.headers() }; // mutable copy, keys lowercased
delete headers['x-frame-options']; // drops DENY and SAMEORIGIN
const csp = headers['content-security-policy'];
if (csp) {
// Keep the rest of the policy, remove only the framing directive.
headers['content-security-policy'] = csp
.split(';')
.filter(directive => !directive.trim().startsWith('frame-ancestors'))
.join(';');
}
await route.fulfill({ response, headers }); // reuse body and status
});
await page.goto('https://app.example.test/checkout');
await expect(page.frameLocator('#pay').getByRole('heading')).toBeVisible();
});
- Restore the third-party session the frame needs. A cookie set by
vendor.testis a third-party cookie when the same domain is loaded inside a frame onexample.test. Modern engines drop it unless it carriesSameSite=None; Secure, and Chromium additionally partitions storage per top-level site. Seed the cookie explicitly on the context rather than relying on a login performed in a different tab, and keep the seed alongside the rest of your saved session data as described in Reusing Login State with storageState.
import { test, expect } from '@playwright/test';
test('third-party session survives inside the frame', async ({ context, page }) => {
await context.addCookies([{
name: 'vendor_session',
value: process.env.VENDOR_SESSION_TOKEN ?? 'seeded-token',
domain: '.vendor.test', // leading dot so the frame's host matches
path: '/',
httpOnly: true,
secure: true, // mandatory companion of SameSite=None
sameSite: 'None', // without this the browser drops it in a frame
}]);
await page.goto('https://app.example.test/checkout');
const widget = page.frameLocator('#pay');
// A signed-in widget renders the saved card instead of the login prompt.
await expect(widget.getByText('Saved card')).toBeVisible();
});
-
Grant permissions and headers to the frame's own origin. Context-level settings are origin-aware.
context.grantPermissions(['camera'], { origin: 'https://pay.vendor.test' })applies to the embedded document, and granting the parent origin alone leaves the widget prompting. The same is true ofcontext.setGeolocation(),httpCredentials, andsetExtraHTTPHeaders()— the last of which applies to every request the context makes, including the frame's, so scope it carefully. Per-context configuration is unpacked in Browser Contexts & Isolation. -
Re-resolve the frame instead of caching handles. Never store a
Frameobject returned bypage.frames()across an action. Third-party widgets re-navigate their own iframe after tokenization, which detaches the old frame and producesError: frame was detachedon the next call. AFrameLocatorholds only a selector path, so it transparently binds to whatever frame currently matches. When the widget swaps in a second iframe for a step-up challenge, express that as a freshframeLocator()chain rather than reusing the first one.
Troubleshooting variants
SecurityError: Blocked a frame with origin … from accessing a cross-origin frame
This message originates in the browser, not in Playwright, and it always means the failing code is executing inside the page. Search the test for page.evaluate, page.$eval, and any helper that walks iframe.contentWindow; those are the only ways to end up under the page's origin. Rewriting the read as a locator chain removes the error entirely, because the driver never asks the parent renderer for the child document. If you genuinely need to run script in the embedded document — reading a computed style the widget applies, for example — call frameLocator(...).locator('body').evaluate(), which executes in the frame's own context where the origin matches.
The frame stays blank although the request returned 200
A framing refusal is invisible in the network waterfall: the response is fetched successfully and then discarded by the renderer, so status codes and timings all look correct. The evidence lives in the console, where Chromium logs the Refused to display line, and in the response headers. Attach a page.on('console') listener as in step 2, or open the run in the trace viewer and read both panels together as described in Reading Network and Console Tabs in Traces. Once you confirm the header, apply step 3. Resist the temptation to launch Chromium with --disable-web-security; it changes the browser under test into something your users never run, masks genuine integration bugs, and does nothing for Firefox or WebKit.
Error: frame was detached during the action
Detachment happens when the embedded document navigates, when the host application re-renders the <iframe> element, or when a widget replaces itself after submitting. If the error names a step you wrote against a cached Frame, switch to frameLocator() so each action re-resolves. If it names a frameLocator step, the frame is being replaced faster than the action completes; anchor on a post-navigation condition first — await expect(page.frameLocator('#pay').getByRole('button', { name: 'Pay' })).toBeVisible() — so the retry loop settles on the final frame before you click. The choice between waiting on network quiescence and waiting on element state is examined in Waiting for Network Idle vs Element State.
Verification
Verify the fix at three levels rather than trusting a green assertion alone. First, assert on frame identity: expect(page.frameLocator('#pay').locator('body')).toBeVisible() fails fast when the document was never committed, and pairing it with an assertion on a role inside the widget proves the driver reached content the parent origin could not read. Second, assert on the headers you rewrote — expect(response.headers()['x-frame-options']).toBeUndefined() inside a page.on('response') handler documents the interception and fails when the vendor changes its policy, which is exactly the change you want a test to catch. Third, record a trace and open it: the frame appears as its own tree in the DOM snapshot, so you can confirm visually that content rendered and see which frame each action targeted. Run the check on every engine in your matrix, because Firefox and WebKit implement site isolation and cookie partitioning differently from Chromium; the project setup for that is in Running Chromium vs Firefox vs WebKit in Playwright, and payment-specific verification is covered in Testing Third-Party Payment Iframes.
Treat the matrix above as the rule of thumb when triaging any new iframe failure: if the failing call runs in the page, the origin is the cause; if it runs through the driver, look at headers, timing, or session state instead. Role-based queries inside the frame behave exactly as they do on the top-level document, so the resilience arguments in getByRole & Accessibility Selectors apply unchanged to embedded widgets — which matters more here than elsewhere, since you do not control the vendor's markup and cannot rely on their class names surviving a deploy.
Frequently Asked Questions
Do I need to launch the browser with --disable-web-security?
No, and you should not. Playwright drives cross-origin frames through the protocol rather than through page script, so the same-origin policy is not what blocks your locators. Disabling web security changes the browser into a configuration no user runs, hides real integration defects such as a missing SameSite=None attribute, and has no equivalent in Firefox or WebKit, so the suite stops being comparable across engines.
Why does page.frame({ url }) return null when frameLocator works?
page.frame() is a synchronous lookup against the frame tree as it exists at that instant. If the iframe has not attached yet — which is normal, since the host page usually injects it after its own load event — the lookup returns null and your next line throws on a null reference. A FrameLocator stores the path instead of the object and re-resolves on every action inside Playwright's retry loop, so it waits for attachment automatically.
Can I intercept requests made by a cross-origin iframe?
Yes. page.route() and context.route() match every request the context issues, regardless of which frame originated it, because interception happens in the network stack rather than in a renderer. You can inspect route.request().frame() to find out which frame is asking, filter on resourceType() to touch only the document, and mock the vendor's API entirely so tests do not depend on a third-party sandbox being available.
Does the frame's own origin affect screenshots and downloads?
No. Screenshots are composited by the browser, so a cross-origin frame appears in page.screenshot() and in visual comparisons exactly as a user would see it, and a download triggered from inside the frame still fires the page-level download event. Only APIs that read or write another document's DOM, cookies, or storage from inside the page are restricted.