Home › Testing › Cypress Cross-Origin Error: Test Multi-Domain Flows
Intermediate 5 min · September 23, 2026

Cypress Cross-Origin Error: Test Multi-Domain Flows

Wrap provider steps in cy.origin() with data via args.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 11 min
  • ✓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
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Cypress Cross-Origin Error on Redirect?

Cypress executes its commands inside the browser context of your application's origin — the scheme, host, and port shown in the URL bar. The browser's same-origin policy forbids scripts in one origin from accessing another origin's DOM, so when your test lands on an identity provider, payment page, or CMS preview at a different origin, plain cy.get() and cy.type() commands are refused and Cypress reports a cross-origin error.

★
Think of your test as a visitor with a badge that only opens doors in one building.

The boundary is browser security, not a Cypress limitation, which is why no flag or setting can waive it.

cy.origin(url, options, callback) is the sanctioned crossing: it injects the Cypress runtime into the named secondary origin and executes your callback there, with control returning to the primary origin afterwards. Because the callback is serialized and replayed across the boundary, it cannot see outer-scope variables — all data crosses through the args option as serializable values.

The origin string must match exactly, protocol and port included.

Two companion practices complete the picture. cy.session() wraps the whole multi-origin login (with cy.origin inside its setup) so the expensive round-trip runs once and restores from cache, validated by an authenticated-state check. And same-super-domain design — proxying or colocating first-party pages behind one test origin — removes the boundary entirely where you control both sides, leaving cy.origin for the providers you genuinely do not own.

Plain-English First

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 cy.get() and cy.type() 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.

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.

cypress/e2e/cross-origin-wall.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
// The wall: plain commands cannot cross origins
cy.visit('/')
cy.contains('Log in with SSO').click()
// Browser is now at https://auth.example.com/login
cy.get('#username').type('qa@example.com')
// ^ CROSS-ORIGIN ERROR: commands still run in the app origin,
//   but the page (and its DOM) belongs to auth.example.com

// Same failure shape on payment and CMS redirects:
// app.example.com -> payments.provider.com -> app.example.com
Try it live
📊 Production Insight
SSO migrations routinely blindside automation because the domain change is decided by security teams and felt by test suites. Get E2E a seat at that migration table.
🎯 Key Takeaway
The error names two origins because commands run in one and the DOM lives in the other — cross it or remove it.

cy.origin(): The Multi-Domain Escape Hatch

The cy.origin() 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.visit() scoped to that origin. When the callback finishes, control returns to the primary origin and the test continues where the redirect left it.

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.

cypress/e2e/sso-login.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// cy.origin(): run commands inside the secondary origin
cy.visit('/')
cy.contains('Log in with SSO').click()

cy.origin(
  'https://auth.example.com',
  { args: { username: 'qa@example.com', password: 's3cr3t!' } },
  ({ username, password }) => {
    // Everything here executes IN auth.example.com
    cy.get('#username').type(username)
    cy.get('#password').type(password, { log: false })
    cy.get('button[type="submit"]').click()
  }
)

// Back on the app origin: prove the round-trip worked
cy.url().should('include', '/dashboard')
cy.get('[data-testid="user-menu"]').should('contain', 'QA')
Try it live
📊 Production Insight
One well-asserted login helper with cy.origin covers every SSO spec in the suite. Write the journey once, reuse it everywhere.
🎯 Key Takeaway
cy.origin(url, { args }, callback) runs commands inside the second origin — match the URL exactly, assert both handoffs.

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.

📊 Production Insight
Undefined-inside-callback bugs waste hours because the code looks correct at the call site. A lint rule against outer references in cy.origin callbacks pays for itself immediately.
🎯 Key Takeaway
Callbacks arrive without your closure — pass serializable data via args and define logic inside.

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.

📊 Production Insight
Testability is an architecture property. The teams with painless multi-domain suites decided their domain topology with testing in mind.
🎯 Key Takeaway
Unify first-party domains behind one test origin — reserve cy.origin for providers you do not control.

Sessions and Cookies Across Origins With cy.session()

A full provider round-trip costs seconds per test — unacceptable multiplied across a suite. cy.session() 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() 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.

cypress/support/commands.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Login once, reuse everywhere: cy.session outside, cy.origin inside
Cypress.Commands.add('loginViaSSO', (username, password) => {
  cy.session(
    username,
    () => {
      cy.visit('/')
      cy.contains('Log in with SSO').click()
      cy.origin(
        'https://auth.example.com',
        { args: { username, password } },
        ({ username, password }) => {
          cy.get('#username').type(username)
          cy.get('#password').type(password, { log: false })
          cy.get('button[type="submit"]').click()
        }
      )
      // Validate INSIDE setup so only good sessions are cached
      cy.url().should('include', '/dashboard')
      cy.get('[data-testid="user-menu"]').should('be.visible')
    },
    {
      validate() {
        cy.request('/api/whoami').its('status').should('eq', 200)
      },
    }
  )
})
Try it live
🔥Cache the Whole Login, Not Half of It
Session setup must run the full journey — visit, origin hop, validation — so the cached cookies and storage represent a genuinely logged-in user. A session cached too early restores a half-logged-in ghost into every test.
📊 Production Insight
Session caching turns SSO suites from minutes per run back to seconds. It is the difference between testing auth in every spec and testing it once.
🎯 Key Takeaway
cy.session() outside, 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.

cypress/e2e/checkout-multiorigin.cy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Three-origin checkout: sequence hops, never nest
it('completes checkout across app, CMS, and payment origins', () => {
  cy.loginViaSSO('buyer@example.com', 's3cr3t!') // app origin, cached
  cy.visit('/checkout')
  cy.get('[data-testid="pay-now"]').click()

  // Hop 1: payment provider (its own block, top level)
  cy.origin(
    'https://payments.provider.com',
    { args: { card: '4242424242424242' } },
    ({ card }) => {
      cy.get('#card-number').type(card)
      cy.get('#pay').click()
    }
  )

  // Back home: prove each handoff before continuing
  cy.url().should('include', '/order-confirmed')
  cy.get('[data-testid="order-number"]').should('be.visible')
})
Try it live
📊 Production Insight
Multi-hop journeys concentrate brittleness. Isolate handoffs into focused specs and the full chain becomes a cheap smoke test instead of a suite-wide liability.
🎯 Key Takeaway
Sequence one block per origin with assertions between hops — and cover the full chain once, not everywhere.
● Production incidentPOST-MORTEMseverity: high

The SSO Migration That Left 25 Login Tests Touching an Untouchable Form

Symptom
All 25 login specs failed with cross-origin errors the day SSO went live. Manual QA passed everything, so releases continued with zero automated auth coverage. Two regressions — a broken MFA prompt and a mis-scoped role — shipped uncaught during the gap.
Assumption
The team assumed Cypress 'sees' whatever is in the browser, like a human watching the screen. Nobody realized commands execute inside one origin's context, so the provider's form was as untouchable as a page in another browser.
Root cause
The new identity provider lived on a different origin than the app. Every Cypress command targeting the provider's username field violated the same-origin policy, so all SSO specs failed at the first keystroke. The suite had been written when auth was a same-origin form, and nothing in the migration plan accounted for the origin boundary.
Fix
They built a 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.
Key lesson
  • 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.
Production debug guideFive checks that place every step in its correct origin with a valid session.5 entries
Symptom · 01
Cross-origin error on the provider's login form
→
Fix
Identify the exact line that touches the second origin (login form fields, provider buttons) and wrap just those steps: 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.
Symptom · 02
Variables are undefined inside the cy.origin callback
→
Fix
Move every outer value the callback needs into the args object and destructure it in the callback signature. Run the spec — undefined values now arrive correctly. As a rule, the callback body should reference nothing except its parameters and commands defined inside it.
Symptom · 03
Multi-domain login works once but repeats (or fails) on every test
→
Fix
Restructure so 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.
Symptom · 04
Origin mismatch after an environment or provider config change
→
Fix
Manually walk the flow and write down each origin from the URL bar, then compare with the strings in your cy.origin calls — protocol, host, and port must match exactly. Fix the mismatched string and add a cy.url().should('include', ...) assertion after each hop so future drift fails loudly.
Symptom · 05
Redirect returns to the app but the user is logged out
→
Fix
After the redirect back, assert 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.
Cypress Cross-Origin Errors — Root Cause vs Fix at a Glance
Root CauseHow to ConfirmFixPrevention
Acted on a secondary origin with plain commandsError names the two origins; failing line touches the login or payment pageWrap those steps in cy.origin(secondaryUrl, callback)Route every cross-origin step through one login helper
Used closure variables inside the cy.origin callbackValues are undefined inside the callback though defined outsidePass data via the { args } optionLint for outer references; keep callbacks self-contained
Session cached on one origin, asserted on anotherLogin passes but the app shows logged-out after redirectPut cy.session() outside, cy.origin() inside its setupValidate sessions with a same-origin whoami check
Domains differ per environmentPasses locally, fails in staging with an origin mismatchUnify domains or branch the helper per environmentDocument the domain map; assert it in a support file
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
cypresse2ecross-origin-wall.cy.jscy.visit('/')Why Cypress Throws a Cross-Origin Error
cypresse2esso-login.cy.jscy.visit('/')cy.origin()
cypresssupportcommands.jsCypress.Commands.add('loginViaSSO', (username, password) => {Sessions and Cookies Across Origins With cy.session()
cypresse2echeckout-multiorigin.cy.jsit('completes checkout across app, CMS, and payment origins', () => {Multi-Hop Flows

Key takeaways

1
Cross-origin errors mean commands targeted a second origin
wrap those steps in cy.origin().
2
The callback is serialized
pass data via { args }, never via closure variables.
3
Nest cy.origin() inside cy.session() setup so multi-domain logins are cached, not repeated.
4
Same-super-domain design avoids cy.origin entirely
prefer it when you own both sides.
5
Assert cy.url() and session validity after every redirect back to your app.
6
One origin per block, sequenced not nested, keeps multi-hop flows debuggable.

Common mistakes to avoid

5 patterns
×

Interacting with the second origin outside cy.origin()

Symptom
Cross-origin error on the exact line that types into the login form or clicks the payment button, because plain commands can only touch the primary origin.
Fix
Wrap every secondary-origin step in 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

Symptom
The callback throws or sees undefined values because its source is serialized and replayed in the secondary origin without your closure.
Fix
Pass values through the 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

Symptom
Sessions never restore across tests or the setup re-runs every time, because the session cache cannot span the origin boundary from the inside.
Fix
Nest the callbacks: 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

Symptom
Green locally where auth is a same-origin path, red in staging where auth is a separate subdomain or provider — the suite has two untested configurations.
Fix
Decide per environment which shape you test, and branch the helper: same-super-domain locally can use plain commands, split domains in staging need cy.origin. Better still, unify the domains so one path works everywhere.
×

Nesting cy.origin() blocks for three-or-more-domain flows

Symptom
Cryptic failures about callback constraints and lost state, because origin callbacks cannot contain further cy.origin calls.
Fix
Keep one origin per cy.origin block and sequence them at the test's top level. Chain cy.origin(a, ...) then cy.origin(b, ...) — never nest — and assert the URL after each hop so a failed redirect surfaces immediately.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What triggers a cross-origin error in Cypress?
Q02SENIOR
Walk me through testing a login that redirects to an external provider.
Q03SENIOR
Why is a variable undefined inside a cy.origin() callback?
Q04SENIOR
When would you avoid cy.origin() even in a multi-domain flow?
Q05SENIOR
How do you keep a three-origin checkout flow debuggable?
Q01 of 05JUNIOR

What triggers a cross-origin error in Cypress?

ANSWER
The test tried to run Cypress commands against a page whose origin differs from the primary one — for example an auth provider or payment page. Browsers forbid cross-origin DOM access, so Cypress throws instead of acting blindly. The fix is cy.origin(), which runs the commands inside the secondary origin.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can I disable the cross-origin check in Cypress instead?
02
What URL do I pass as the first argument to cy.origin()?
03
Can cy.origin() and cy.session() be used together?
04
What data can I pass through the args option?
05
How do I know the redirect back to my app actually worked?
06
What if my flow crosses three or more origins?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Cypress. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Cypress Element Detached From DOM
3 / 4 · Cypress
Next
Cypress Flaky Tests: Why You Should Not Use cy.wait(ms)
→