Cypress Cross-Origin Error: Test Multi-Domain Flows
Wrap provider steps in cy.origin() with data via args.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓A Cypress suite with (or planning) login via an external provider
- ✓Basic familiarity with cookies, redirects, and browser origins
- ✓Access to a test SSO account you can safely automate
- Cypress commands execute inside your app's origin, so touching a provider or payment page throws a cross-origin error
- Wrap secondary-origin steps in cy.origin('https://provider.com', { args }, (args) => {...}) and assert cy.url() after returning
- The callback is serialized — pass all data through the args option, never through closure variables
- Nest cy.origin() inside cy.session() setup so the multi-domain login runs once and restores from cache
Think of your test as a visitor with a badge that only opens doors in one building. Clicking SSO walks them into a second building where the badge does not work — the doors refuse to open. cy.origin() is a temporary visitor pass for the second building: it lets your test operate the login form there, then escorts it back to the first building with the stamp proving it logged in.
Your test clicks 'Log in with SSO', the browser lands on your identity provider, and Cypress dies with a cross-origin error. The login form is right there — you can see the username field — but every command you send is refused. It feels like Cypress gave up one step from the finish line.
It did not give up; it hit a browser security wall. Cypress commands execute inside your app's origin, and the same-origin policy forbids them from reaching into the provider's page. Before cy.origin() existed, teams resorted to API-login workarounds, disabled security, or simply did not test SSO at all. Now there is a first-class answer: run those commands inside the second origin, pass data across safely, and come home with a valid session.
This article shows you exactly that. You will learn the cy.origin() shape — origin string, args, callback — why the callback cannot see your variables, how to pair it with cy.session() so multi-domain logins run once instead of per test, and when to sidestep the whole problem with same-super-domain design. Multi-domain flows stop being the tests everyone skips.
Why Cypress Throws a Cross-Origin Error
Browsers isolate origins — scheme, host, and port together — so a script running in https://app.example.com cannot read or write the DOM of https://auth.example.com. Cypress commands are scripts running in your app's origin, which means the moment a test lands on a second origin, every and cy.get() is a cross-boundary access the browser must refuse. Cypress surfaces that refusal as the cross-origin error instead of letting commands silently operate on the wrong document.cy.type()
This bites exactly where modern apps delegate: single sign-on providers, OAuth consent screens, payment checkouts, and headless CMS previews. Each hands the browser to a domain you do not control, then redirects home. Tests written when auth was a same-origin form keep working until the migration week, then fail at the first keystroke on the provider's page — while manual QA passes everything, hiding the automation gap.
Recognize the signature early: the error names two origins, and the failing line touches the foreign page. That combination never means a bad selector or a slow load — it means an origin boundary stands between your commands and the DOM. Either bring the commands across with cy.origin() or remove the boundary with same-super-domain design. Everything else in this article is execution of one of those two moves.
cy.origin(): The Multi-Domain Escape Hatch
The command takes an origin string, an optional args object, and a callback — and executes that callback inside the secondary origin with the Cypress runtime injected there. Commands inside the block target the foreign page normally: get, type, click, and even cy.origin() scoped to that origin. When the callback finishes, control returns to the primary origin and the test continues where the redirect left it.cy.visit()
The origin string must match exactly, including protocol and port — https://auth.example.com is not http://auth.example.com and not https://auth.example.com:8443. Copy it from the URL bar during a manual walkthrough rather than typing it from memory. A near-miss produces a mismatch error that reads like a Cypress bug but is really a typo.
Keep each block focused on one origin and assert the handoff on both sides: the provider shows its challenge (username field visible) before you type, and your app shows the authenticated state (user menu, dashboard URL) after you return. Those two assertions turn silent redirect failures — landing back logged-out — into loud, precisely located errors. Multi-origin tests are inherently harder to read, so the assertions double as documentation of the expected journey.
Passing Data With args: The Serialization Rule
The cy.origin callback travels to the secondary origin as serialized text, not as a live closure — which means it arrives without your variables, imports, or helper functions. Anything the callback references from the outer scope is undefined when it runs. This is the single most confusing cy.origin behavior, and it is a direct consequence of executing code in another origin's context.
The args option is the sanctioned bridge: a plain object of serializable values (strings, numbers, booleans, plain objects, arrays) that Cypress carries across and injects as the callback's parameter. Credentials, test emails, and feature flags cross this way. Functions, class instances, DOM nodes, and Cypress chainables do not survive — if the callback needs logic, define it inside the callback body.
Design callbacks to be self-contained: destructure args in the signature, use only commands and plain code inside, and keep the block short. Long callbacks that smuggle half the test through args become unreadable and brittle. If a provider flow needs elaborate logic, extract it into a custom command whose body is the cy.origin call — the command boundary keeps the serialization rule visible instead of surprising the next reader.
Same-Super-Domain Design: Avoid cy.origin Entirely
When you own both sides of a multi-domain flow, the cheapest fix is removing the boundary instead of crossing it. Pages on the same super-domain — app.example.com and auth.example.com — can be unified behind one test origin with a reverse proxy, path-based routing (/auth served by the provider behind your domain), or environment config that colocates them. Single origin means plain Cypress commands throughout: no cy.origin, no serialization rules, faster tests.
This is a design decision with compounding returns. Provider-hosted pages you do not control (public SSO, Stripe checkout) still need cy.origin, but every first-party handoff you unify deletes an entire category of test complexity. Raise it during architecture reviews, not after the suite breaks: asking 'can test reach both pages on one origin?' costs nothing at design time and rewrites weeks of test maintenance later.
Be honest about the trade-off. Proxying auth in test environments diverges from production topology, so keep one smoke spec running against the real split domains (with cy.origin) to prove the production shape works. Unify for speed and simplicity across the suite; verify reality with a focused few. That layered approach gives you maintainable tests without lying to yourself about what production looks like.
Sessions and Cookies Across Origins With cy.session()
A full provider round-trip costs seconds per test — unacceptable multiplied across a suite. solves it by running the login once, caching the resulting cookies, local storage, and session storage, then restoring them for later tests. The critical structural rule: cy.session()cy.session() goes outside, cy.origin() goes inside its setup function. The session wraps the whole multi-origin journey.
Validation decides whether the cache is trustworthy. Assert the authenticated state inside the setup (dashboard URL, user menu) so a failed login never gets cached as success, and add a validate function hitting a session endpoint like /api/whoami so stale sessions are detected and rebuilt. Without validation, an expired session restores a logged-out ghost into every test and the failures scatter across unrelated specs.
Reversed nesting — cy.origin outside, cy.session inside — breaks because the session cache cannot coherently span the boundary from within the foreign origin. If your sessions mysteriously re-run setup every test or restore empty, check the nesting first. Session outside, origin inside, validation always: that shape scales from one SSO spec to hundreds without growing suite time.
cy.origin() inside setup, validation always — log in once, restore everywhere.Multi-Hop Flows: Sequencing Three or More Origins
Real flows chain multiple hops: app to CMS preview to payment provider and home again. The rules that keep these debuggable are simple but strict. One cy.origin block per origin, sequenced at the test's top level — callbacks cannot contain further cy.origin calls, so nesting is not merely ugly, it is illegal. Assert the URL and a visible outcome after every hop so a failed redirect is caught at the hop, not three pages later.
Keep each hop's args minimal and explicit: the card number for the payment block, the article slug for the CMS block. Minimal args make each block readable in isolation and prevent accidental coupling where the payment step depends on variables from the auth step. Shared setup (login) belongs in cy.session-backed helpers that run before the journey starts.
When the chain grows beyond two hops, resist covering the full journey in every test. Test each handoff in its own spec and keep one end-to-end smoke test for the complete chain. Full-chain tests are inherently slower and more brittle; concentrating that cost in a single spec while the handoffs enjoy focused coverage gives you the same confidence at a fraction of the flakiness. Keep the smoke test's hops few and its assertions loud.
The SSO Migration That Left 25 Login Tests Touching an Untouchable Form
loginViaSSO custom command wrapping the provider steps in cy.origin() with credentials via args, nested inside cy.session() so it ran once per user. SSO coverage went from zero automated tests to the full login matrix, and suite time grew by seconds instead of minutes thanks to session caching.- Untestable login flows rot fast — every SSO migration needs its Cypress strategy designed alongside it, not after.
- Cache expensive logins with cy.session from day one; per-test provider round-trips make suites too slow to keep.
- Assert the session after every redirect. A login that lands back unauthenticated is worse than one that errors — it poisons downstream tests silently.
cy.origin('https://auth.example.com', { args: { username, password } }, ({ username, password }) => { cy.get('#username').type(username); ... }). Keep the pre-redirect and post-redirect assertions outside the block on your app origin.cy.session() is outermost: cy.session(user, () => { cy.visit('/'); cy.origin(providerUrl, { args }, (...) => {...}) }), with a validate step asserting the app session. The provider round-trip now runs once per user and restores from cache afterwards.cy.url().should('include', ...) assertion after each hop so future drift fails loudly.cy.url() is your app origin and hit a session endpoint (cy.request('/api/whoami').its('status').should('eq', 200)). If the app shows logged-out, inspect which origin holds the tokens — the session setup likely needs an explicit visit or cookie step on the app origin after returning.| File | Command / Code | Purpose |
|---|---|---|
| cypress | cy.visit('/') | Why Cypress Throws a Cross-Origin Error |
| cypress | cy.visit('/') | cy.origin() |
| cypress | Cypress.Commands.add('loginViaSSO', (username, password) => { | Sessions and Cookies Across Origins With cy.session() |
| cypress | it('completes checkout across app, CMS, and payment origins', () => { | Multi-Hop Flows |
Key takeaways
cy.origin().cy.origin() inside cy.session() setup so multi-domain logins are cached, not repeated.cy.url() and session validity after every redirect back to your app.Common mistakes to avoid
5 patternsInteracting with the second origin outside cy.origin()
cy.origin('https://auth.example.com', () => {...}). If the URL bar shows a different origin than your baseUrl when the command runs, it belongs inside cy.origin.Referencing outer-scope variables inside the cy.origin callback
args option: cy.origin(url, { args: { email } }, ({ email }) => {...}). Only serializable data crosses the boundary — compute everything else inside.Putting cy.session() inside cy.origin() instead of outside
cy.session() on the outside holding the whole login, with cy.origin() inside its setup function. The session then caches the multi-origin result and reuses it.Assuming local and staging have the same domain shape
Nesting cy.origin() blocks for three-or-more-domain flows
cy.origin(a, ...) then cy.origin(b, ...) — never nest — and assert the URL after each hop so a failed redirect surfaces immediately.Interview Questions on This Topic
What triggers a cross-origin error in Cypress?
cy.origin(), which runs the commands inside the secondary origin.Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Cypress. Mark it forged?
5 min read · try the examples if you haven't