Your test clicks "Sign in with Google", or follows a link to a payment provider, and Cypress stops with a wall of red text:
CypressError: Cypress detected a cross origin error happened on page load:
> Blocked a frame with origin "http://localhost:3000" from accessing
a cross-origin frame.
Nothing is broken in your application. Cypress hit a rule the browser enforces on it, and there is a supported way through.
Why this happens
Cypress runs your test inside the browser, in the same window as your app. That is what gives it the time-travel debugger and the automatic waiting. It also means your test code is page code, and page code obeys the browser's rules.
The rule here is the same-origin policy. A page from one origin cannot read or control a page from another origin. An origin is the combination of protocol, domain and port, so all of these are different origins:
http://localhost:3000 vs https://localhost:3000 (different protocol)
https://app.example.com vs https://pay.example.com (different subdomain)
https://example.com vs https://example.com:8080 (different port)
When your test navigates somewhere new, Cypress is now on the wrong side of that boundary and cannot see the page. That is the error.
Selenium and Playwright never show it, because they drive the browser from outside and the policy does not apply to them. This is the clearest example of the tradeoff Cypress makes, and it is covered in Cypress vs Selenium.
The fix: cy.origin()
cy.origin() tells Cypress to run a block of commands against a different origin.
cy.visit('/');
cy.get('[data-testid="pay"]').click(); // sends us to the payment provider
cy.origin('https://pay.example.com', () => {
cy.get('#card-number').type('4242424242424242');
cy.contains('button', 'Pay').click();
});
// back on our own origin
cy.get('[data-testid="order-confirmed"]').should('be.visible');
Commands inside the callback run on that origin. Commands after it run on yours again.
The trap that catches everyone: the callback is not a closure
This is the part that produces the confused second search, and it is worth reading carefully.
The callback is stringified, sent to a separate Cypress instance, and evaluated there. The Cypress documentation states it directly: the callback "is not a closure and does not retain access to the JavaScript context in which it was declared".
So this does not work:
const email = 'demo@nimbus.app';
cy.origin('https://auth.example.com', () => {
cy.get('#email').type(email); // ReferenceError: email is not defined
});
Pass values through the args option instead. The documentation calls it the only mechanism for getting data into the callback:
const sentArgs = { username: 'demo@nimbus.app', password: 'secure123' };
cy.origin(
'https://auth.example.com',
{ args: sentArgs },
({ username, password }) => {
cy.visit('/login');
cy.get('input#username').type(username);
cy.get('input#password').type(password);
cy.contains('button', 'Login').click();
},
);
Note the shape: origin first, then the options object, then the callback. Forgetting the middle argument is a common slip.
Three commands you cannot use inside the callback
These throw if you call them in a cy.origin() block:
cy.origin()itself, so no nesting.cy.intercept()cy.session()
Set up your intercepts before the cy.origin() block. They keep working across the boundary.
cy.intercept('POST', '**/api/charge').as('charge'); // outside, before
cy.origin('https://pay.example.com', () => {
cy.contains('button', 'Pay').click();
});
cy.wait('@charge'); // outside, after
Imports do not work either
Inside the callback you cannot use require() or import(). If you need a helper, use Cypress.require(), which needs the experimentalOriginDependencies option turned on.
In practice, most teams avoid this entirely by keeping the callback small and passing plain values through args.
What about chromeWebSecurity: false?
You will find this suggested in a lot of old answers:
// cypress.config.js
module.exports = defineConfig({
chromeWebSecurity: false,
});
It does work. It lets you navigate to any origin without the error, and it allows access to cross-origin iframes.
Be careful with it, for two reasons.
It only works in Chrome-based browsers, by Cypress's own documentation. Turn it on and your suite quietly stops being runnable in Firefox.
And it turns off a browser security model to make a test pass. Your test is now running in a browser that behaves differently from your users' browsers, which is the opposite of what a test is for.
Use cy.origin() as the default. Keep chromeWebSecurity: false for the cases it genuinely solves, mostly cross-origin iframes, and know what you traded.
The subdomain case
Two subdomains of the same site are still different origins. app.example.com and docs.example.com need cy.origin() just like unrelated domains do.
Older Cypress had an injectDocumentDomain option that relaxed this between subdomains. It is deprecated and will be removed, so do not build on it.
The fix people forget: split the test
Sometimes you do not need cy.origin() at all.
Cypress only complains when a single test crosses origins. Different tests can visit different origins freely. So if your test is really two behaviours stitched together, split it:
it('signs in through the identity provider', () => {
// everything on auth.example.com
});
it('shows the dashboard for a signed-in user', () => {
// set the session directly, then test our own app
});
This is usually the better test anyway. A test that spans two products tends to fail for reasons that have nothing to do with your code, and third-party downtime becomes your red build. Fixing flaky tests covers why depending on services you do not control is a losing position.
Should you test the third-party flow at all?
Worth asking before you spend a day on cy.origin().
You do not need to test that Google's login page works. Google tests that. What you need to test is that your application does the right thing with the result.
For most teams the honest answer is:
- Test your own login form properly, with real assertions.
- For third-party sign-in, test it once as a smoke check, or set the session directly and skip the provider.
- Never make the provider a dependency of every test in the suite.
I have watched a team's whole pipeline go red because an identity provider had a slow morning. Nothing they owned was broken, and they could not ship.
Quick reference
| Situation | What to do |
|---|---|
| One test visits a second domain | cy.origin() |
| You need variables inside the callback | Pass them via { args } |
You need cy.intercept() |
Set it up before the block |
| Different subdomains | Still needs cy.origin() |
| Cross-origin iframe | chromeWebSecurity: false, Chrome only |
| Two unrelated behaviours | Split into two tests |
| Third-party login in every test | Set the session directly instead |
Remember this
The error is not a bug. Cypress runs inside the browser, so it obeys the same-origin policy, and cy.origin() is the supported way across.
The one thing to keep in your head: the callback is not a closure. Anything it needs goes through args. That single fact explains most of the confusion after the first fix.
The official guide is cross-origin testing and the cy.origin() API reference.
If cross-origin flows are a regular part of your app, it is worth knowing that Playwright treats them as ordinary navigation. Playwright vs Cypress covers where that difference matters.