Playwright architecture, selector reliability, and advanced interaction patterns.

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.

Where a cross-origin iframe command actually runs Playwright commands travel from the test process through the browser driver channel into both renderer processes, while in-page JavaScript is stopped by the same-origin policy between them. Playwright test process (Node) over CDP Browser process driver channel Renderer: parent page app.example.test Renderer: iframe pay.vendor.test same-origin policy blocks in-page reads
Playwright talks to both renderers from outside the page, so the dashed same-origin boundary constrains in-page JavaScript but not locator commands.

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

  1. Reach the frame through the driver, never through page JavaScript. Replace every contentDocument, contentWindow, and postMessage scraping hack with page.frameLocator(selector) or the newer page.locator(selector).contentFrame(). Both return a lazy handle you chain role and text queries onto, exactly as you would on page. If you already index frames by name or URL, prefer the locator form: page.frame({ url: /vendor/ }) returns null the instant the frame has not attached yet, whereas a FrameLocator waits. Chaining rules for deeper structures are covered in Automating Elements Inside Nested Iframes.

  2. 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());
  }
});
  1. Remove the framing refusal at the network layer. If the vendor sends X-Frame-Options: DENY or a Content-Security-Policy containing frame-ancestors 'none', the browser commits an empty document and prints Refused 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 with route.fetch() plus route.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();
});
  1. Restore the third-party session the frame needs. A cookie set by vendor.test is a third-party cookie when the same domain is loaded inside a frame on example.test. Modern engines drop it unless it carries SameSite=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();
});
  1. 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 of context.setGeolocation(), httpCredentials, and setExtraHTTPHeaders() — 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.

  2. Re-resolve the frame instead of caching handles. Never store a Frame object returned by page.frames() across an action. Third-party widgets re-navigate their own iframe after tokenization, which detaches the old frame and produces Error: frame was detached on the next call. A FrameLocator holds 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 fresh frameLocator() chain rather than reusing the first one.

How a framing refusal becomes a locator timeout A sequence showing the parent page requesting the vendor document, receiving a successful response carrying X-Frame-Options DENY, and the locator timing out on an empty frame. Playwright test Parent page pay.vendor.test page.goto('/checkout') GET /widget for iframe 200 with X-Frame-Options renderer drops the frame TimeoutError after 30000ms
The HTTP request succeeds, which is why the network panel looks healthy while the locator inside the frame never resolves.

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.

Diagnosing a locator timeout inside a cross-origin frame A decision tree that splits on whether the frame URL appears in page.frames, leading to header inspection or console inspection and then to a network-layer fix. Locator inside iframe times out URL absent URL present Frame never attached Frame attached but blank Read X-Frame-Options and frame-ancestors Read console errors in the trace viewer Rewrite headers with page.route()
Splitting on whether the frame URL appears in page.frames() separates a refusal to embed from a frame that loaded but rendered nothing.

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.

Which operations cross the origin boundary A matrix comparing four operations against same-origin and cross-origin frames, showing that only in-page evaluation and cookie access are restricted. Crossing the origin boundary Operation Same-origin Cross-origin page.evaluate() into the frame works SecurityError frameLocator() click and fill works works page.route() interception works works document.cookie in the frame works partitioned by browser
Only operations that execute inside the parent renderer are constrained; everything routed through the driver behaves identically for both origins.

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.

Back to overview