How to Type Within an iFrame with Cypress
Learn the reliable Cypress pattern for typing into same-origin iframes, handling timing, cross-origin limits, browser differences, and failures.

To type into a same-origin iframe with Cypress, wait for the iframe body, wrap that body with Cypress, find the input, and call .type():
cy.get('iframe')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('input')
.type('your text')
Replace iframe and input with selectors that uniquely identify your frame and field. This works when the parent page and the embedded document share the same scheme, hostname, and port. Cypress retries .its() while the frame becomes available, and the body assertion prevents the test from querying an empty document. Cypress documents this approach in its iframe FAQ (Cypress Documentation FAQ).
1. Understand the origin before writing the test
An origin is the combination of scheme, hostname, and port. For example, https://app.example.test:443 and https://payments.example.test:443 have different hostnames and therefore different origins, even though they share a parent domain. A frame loaded from the same origin can be read through contentDocument. A cross-origin frame is protected by the browser’s same-origin policy.
| Situation | Correct Cypress approach |
|---|---|
| Embedded iframe, same origin | Read contentDocument.body, wrap it, then query and type. |
| Embedded iframe, different origin | Normal Cypress commands cannot read the frame document. |
| Top-level navigation to another origin | Use cy.origin() for commands on the navigated page. |
| Chromium test with a cross-origin embedded frame | chromeWebSecurity: false is a documented, browser-limited workaround. |
Do not treat an iframe as a top-level navigation. cy.origin() changes the command context for a page you navigated to; it does not switch into a nested iframe. Cypress’s API reference explains the scope of cy.origin() (cy.origin() documentation).
2. Use a stable selector for the frame and field
Give the iframe and its input durable selectors. A test id or name is generally safer than a positional selector such as iframe:first.

<iframe data-cy="profile-frame" src="/profile-form.html"></iframe>
<!-- inside profile-form.html -->
<input data-cy="display-name" name="displayName" type="text">
The corresponding test is:
cy.get('[data-cy="profile-frame"]')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('[data-cy="display-name"]')
.should('be.visible')
.type('Ada Lovelace')
Keep the chain attached to the wrapped body. Once Cypress has a wrapped body, normal queries such as .find(), .contains(), assertions, and actions operate inside that document.
3. Add waits for asynchronous frame content
The iframe body can exist before an application has inserted the input. Wait for the actual field rather than adding an arbitrary sleep:
cy.get('[data-cy="profile-frame"]')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('[data-cy="display-name"]', { timeout: 10000 })
.should('be.visible')
.type('Ada Lovelace')
For a field that is enabled only after setup, assert that state before typing:
cy.get('[data-cy="profile-frame"]')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('input[name="email"]', { timeout: 15000 })
.should('be.visible')
.and('not.be.disabled')
.type('ada@example.test')
Use a fixed cy.wait(2000) only when an external contract truly requires a delay. A selector or state assertion retries and finishes as soon as the application is ready.
4. Package the pattern as a reusable helper
If several tests access the same frame, create a helper that returns the wrapped body:
const getIframeBody = (frameSelector = 'iframe') =>
cy.get(frameSelector)
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
it('types into the profile form', () => {
getIframeBody('[data-cy="profile-frame"]')
.find('[data-cy="display-name"]')
.type('Ada Lovelace')
})
You can place the helper in a support file and use it across specs. Keep the helper focused on frame readiness; let each test choose the field selector and expected value.
5. Handle fields that are replaced or re-rendered
Modern form libraries may replace an input after mounting. Query the field immediately before the action and avoid holding a stale DOM reference:
getIframeBody('[data-cy="checkout-frame"]')
.find('input[name="cardholder"]', { timeout: 15000 })
.should('exist')
.type('Ada Lovelace')
If typing triggers a re-render, split actions and reacquire the body and field for the next action:
getIframeBody('[data-cy="checkout-frame"]')
.find('input[name="firstName"]')
.type('Ada')
getIframeBody('[data-cy="checkout-frame"]')
.find('input[name="lastName"]')
.type('Lovelace')
Use .clear().type() when a field contains a default value. Use options such as { delay: 20 } only when the application depends on per-key events; slower typing increases test time.
6. Cross-origin embedded iframes
When the frame’s origin differs from the parent, contentDocument is unavailable to the test. This is a browser security boundary, not a missing Cypress selector. The same-origin snippet will commonly produce a null document or fail while trying to access the body.

Cypress documents chromeWebSecurity: false as a workaround for cross-origin embedded frames in Chromium-family browsers. In cypress.config.js:
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
chromeWebSecurity: false
}
})
This setting is unsupported in Firefox and WebKit, so it is not a portable iframe API. It also changes browser security behavior for the test run. Use it only when your supported browser matrix is compatible and verify the guidance for your Cypress version in the Cypress cross-origin documentation.
If you control the application, a same-origin test fixture or a test-only proxy can make the frame accessible. If you do not control the embedded provider, test the integration boundary you own: verify that the iframe is mounted, has the expected source, receives the required configuration, and reports success through a documented callback or network request. Do not attempt to bypass browser policy with arbitrary JavaScript injection.
7. Why cy.origin() is different
cy.origin() is for a top-level visit or redirect to another origin:
cy.visit('https://login.example.test')
cy.origin('https://login.example.test', () => {
cy.get('input[name="username"]').type('ada')
})
That example is not a solution for an embedded login iframe. The frame remains nested inside the original page, and cy.origin() does not provide commands inside it.
As of Cypress 14.0.0, Cypress no longer injects document.domain into text/html pages by default. Consequently, cy.origin() is required for top-level navigation between origins, including origins that share a superdomain. The injectDocumentDomain option can temporarily restore older behavior, but Cypress marks it deprecated and plans to remove it. This version change affects top-level navigation; it does not turn cy.origin() into an iframe mechanism.
8. Debugging checklist
| Symptom | Likely cause | Fix |
|---|---|---|
contentDocument is null |
The frame is cross-origin, detached, or has not loaded. | Check scheme, host, and port; wait for a non-empty body; use the Chromium workaround only when appropriate. |
| Body is empty | The frame document is still loading. | Keep .its('0.contentDocument.body').should('not.be.empty') in the chain. |
| Field not found | The application inserts it after the body or uses a different document. | Query the real field with a longer timeout and inspect the frame’s DOM in the Cypress runner. |
| Element is covered or not actionable | A loading layer, focus trap, or validation message covers it. | Wait for the overlay to disappear and assert visibility/enabled state. Avoid { force: true } unless the interaction is intentionally non-user-like. |
cy.origin() did not help |
The target is an embedded frame. | Use the same-origin body pattern or redesign the test around an integration boundary. |
| Works in Chrome but not Firefox/WebKit | chromeWebSecurity: false is Chromium-only. |
Provide a same-origin fixture or a browser-specific test plan. |
| Typing stops after the first character | Framework re-rendered and replaced the input. | Re-query before each action and assert the field is enabled. |
9. A complete Cypress example
describe('profile form in an iframe', () => {
const frame = '[data-cy="profile-frame"]'
const iframeBody = () =>
cy.get(frame)
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
it('fills and submits the form', () => {
cy.visit('/settings/profile')
iframeBody()
.find('input[name="displayName"]', { timeout: 10000 })
.should('be.visible')
.clear()
.type('Ada Lovelace')
iframeBody()
.find('textarea[name="bio"]')
.type('Mathematician and writer')
iframeBody()
.find('button[type="submit"]')
.should('be.enabled')
.click()
cy.get('[data-cy="save-status"]')
.should('contain', 'Saved')
})
})
The final assertion is on the parent page because the application may report success outside the frame. If the confirmation is rendered inside the iframe, use iframeBody().find(...) for that assertion instead.
10. Performance and reliability practices
- Use one precise frame selector instead of scanning every iframe.
- Prefer test ids and names that remain stable through CSS refactors.
- Wait on meaningful readiness states, not long global sleeps.
- Keep cross-origin tests in a clearly identified browser configuration.
- Separate frame access from business assertions so failures identify whether loading, typing, or submission broke.
- Use realistic typing options only when the application needs keyboard timing; most inputs can be filled at Cypress’s default speed.
- When a provider is unavailable, record the integration result through a supported callback or API rather than trying to inspect its private DOM.
11. Or skip the browser setup
If your goal is to capture the page or an iframe-containing workflow rather than interact with it, ScreenshotNeo provides a single screenshot API request. Its capture can accept cookie banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for AI agents.
See the complete option list and authentication details in the ScreenshotNeo API documentation.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
For repeated captures, use a TTL you choose for caching, request only the viewport or full-page mode you need, and use bulk capture for up to 100 URLs per call. Custom CSS and JavaScript, selectors to hide, a CSS selector for one element, device presets, retina scale, headers, cookies, user agent, Authorization, timezone, geolocation, request blocking, waits, signed links, asynchronous jobs, webhooks, usage data, HTML/CSS input, PDF output, and image resizing are available on every plan.
The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free. Create a free ScreenshotNeo account and start with the included 1,000 screenshots.
12. Frequently asked questions
Does Cypress have a dedicated switch-to-iframe command?
No. Cypress’s FAQ states that same-origin iframe interaction can use existing commands around contentDocument.body; a third-party plugin is usually unnecessary.
Can I type into an iframe by using cy.get('iframe').type()?
No. The input belongs to the iframe document. First obtain and wrap that document’s body, then find the input inside it.
Will this work when the frame is hosted on another subdomain?
Not under the normal same-origin policy. A different hostname is a different origin, even when both hosts share a parent domain.
Should I use force: true to solve iframe failures?
Usually no. Force bypasses actionability checks but cannot bypass same-origin restrictions or make a missing field exist. Fix origin, readiness, selector, or overlay problems first.
Is the Cypress pattern specific to Chromium?
The same-origin pattern is based on browser DOM access. The documented chromeWebSecurity: false cross-origin workaround is Chromium-family only and unsupported in Firefox and WebKit.


