Cypress: Sessions, Authentication & Cross-Origin

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Cypress clears cookies, local storage and session storage before every test, so each test that needs a signed-in user has to sign in again. This page covers the tools that keep that cheap and safe: cy.session() to cache a login, programmatic and form logins, the official authentication guides for identity providers, cy.origin() to work on more than one origin in a test, the Cypress 16 split of environment variables into cy.env() (secrets) and Cypress.expose() (public values), and the network settings that matter for protected environments: client certificates, trustedCertificates and proxies. It targets Cypress 16.2.0 and was checked on 25 October 2026. The examples were run against the Bookshelf demo application described in Example Application & Test Layout, in Electron and in Google Chrome 155, unless a sentence says otherwise. The specs and support files shown are the real files that ran. Test isolation and the command queue are explained in Cypress: Core Concepts; custom commands, which carry the login helpers here, are in Cypress: Queries, Assertions & Custom Commands. The same problem in Playwright is solved with saved storage state; see Playwright: Authentication & Isolation to compare.

How Cypress handles a signed-in user

Since Cypress 12, testIsolation is on by default: before each test Cypress navigates to about:blank and clears cookies, localStorage and sessionStorage for every origin. A login done in a before hook therefore does not survive into the next test. There are three ways to deal with that:

Approach What it does Trade-off

Sign in through the form in every test

Realistic, exercises the real login page.

Slow, and a broken login page fails every test.

Sign in through the API in every test (cy.request())

Cookies set by the response are stored in the browser automatically; tokens go to localStorage by hand.

Fast, but still one request per test, and the login page is not covered.

Cache the login with cy.session()

Runs the login once, then restores cookies, localStorage and sessionStorage for later tests.

Needs a correct session id and a validate function, otherwise tests can start from a stale login.

The recommended pattern is to test the login page itself in one or two specs and to sign in programmatically, wrapped in cy.session(), everywhere else. Turning testIsolation off to carry a login between tests is possible but discouraged, because a test can then pass or fail depending on the tests that ran before it.

cy.session()

cy.session(id, setup)
cy.session(id, setup, options)

cy.session() creates a session the first time it is called with a given id and restores it on later calls. It yields null, so anything a login returned has to be asserted inside setup.

Argument or option Meaning

id

String, array or object. Arrays and objects are serialized deterministically (['Jane', '123', 'admin'] becomes ["Jane","123","admin"]). The id shows in the Command Log, so never put a password or token in it. It must differ for every distinct session: if the login depends on several parameters, include them all, or two logins will collide on one cache entry.

setup

Function that performs the login. It runs when no session is cached for the id, or when a restored session fails validation. Cookies, localStorage and sessionStorage of all origins are cleared before it runs, whatever testIsolation says; the page is cleared too when testIsolation is on. After setup (and validate) succeed, Cypress stores the cookies and storage.

options.validate

Function that checks the session right after setup, and after a session is restored. If it throws, contains a failing Cypress command, returns a promise that rejects or resolves to false, or its last command yields false, the session is invalid. Invalid after setup: the test fails. Invalid after a restore: setup runs again.

options.cacheAcrossSpecs

Default false. When true the session is global: any spec in the same cypress run on the same machine can restore it.

Two rules shape every login helper. First, cy.session() leaves the page blank, so call cy.visit() after it, in the test or in a beforeEach, not inside the helper unless every test starts on the same page; asserting on the URL or the DOM inside setup is the way to be sure the login finished before it is cached. Second, put the call inside a custom command or helper instead of repeating it in specs, so the id, setup and validate stay identical everywhere.

A login command for Bookshelf

Bookshelf signs users in with POST /api/session and a cookie named bookshelf_session. The command below signs in through the API, reads the password with cy.env() (explained later on this page) and validates a restored session with GET /api/session. /api/test/reset clears every server-side session, so validation is what keeps a cached login honest after a reset. A second command signs in through the form for specs that cover it. These are the real cypress/support/commands.ts contents:

type Role = 'reader' | 'admin';

declare global {
  namespace Cypress {
    interface Chainable {
      /** Sign in through the API once per role and reuse the cached session. */
      signIn(role: Role): Chainable<void>;
      /** Sign in through the form once per role and reuse the cached session. */
      signInWithForm(role: Role): Chainable<void>;
    }
  }
}

// The password keys are read with cy.env(), so the values never reach the browser state.
const passwordKey = { reader: 'READER_PASSWORD', admin: 'ADMIN_PASSWORD' } as const;

Cypress.Commands.add('signIn', (role: Role) => {
  cy.session(
    ['api', role],
    () => {
      cy.env([passwordKey[role]]).then((env) => {
        cy.request({
          method: 'POST',
          url: '/api/session',
          body: { username: role, password: env[passwordKey[role]] },
          log: false,
        })
          .its('body.authenticated')
          .should('eq', true);
      });
    },
    {
      // /api/test/reset clears the server-side sessions, so re-check a restored session.
      validate() {
        cy.request('/api/session').its('body.name').should('eq', role);
      },
    },
  );
});

Cypress.Commands.add('signInWithForm', (role: Role) => {
  cy.session(
    ['form', role],
    () => {
      cy.visit('/signin');
      cy.get('[data-testid=username]').type(role);
      cy.env([passwordKey[role]]).then((env) => {
        cy.get('[data-testid=password]').type(env[passwordKey[role]], { log: false });
      });
      cy.get('[data-testid=signin-submit]').click();
      cy.location('pathname').should('eq', '/account');
    },
    {
      validate() {
        cy.request('/api/session').its('body.name').should('eq', role);
      },
    },
  );
});

export {};

Note the ids ['api', role] and ['form', role]. The two logins produce different session data, so they must not share a cache entry; the official documentation uses the same idea (['loginByForm', name] and ['loginByApi', name]) for apps with several login methods.

Using the commands in specs

The spec below signs in, shows that the second test restores the session instead of logging in, shows the blank page that follows cy.session(), switches between the reader and the admin inside one test without signing out, and covers the form login. It also inspects what Cypress stored with Cypress.session.getCurrentSessionData():

describe('cy.session with a programmatic login', () => {
  beforeEach(() => {
    cy.signIn('reader');
    // The page is blank after cy.session(), so visit what the test needs.
    cy.visit('/account');
  });

  it('starts signed in as the reader', () => {
    cy.get('[data-testid=nav-account]').should('be.visible');
    cy.contains('h1', 'reader');
  });

  it('restores the cached session in the next test', () => {
    cy.get('[data-testid=nav-account]').should('be.visible');
    cy.get('[data-testid=nav-admin]').should('not.exist');
  });

  it('is blank right after cy.session()', () => {
    cy.signIn('reader');
    cy.url().should('eq', 'about:blank');
  });
});

describe('switching roles inside one test', () => {
  it('moves between the reader and the admin without signing out', () => {
    cy.signIn('reader');
    cy.visit('/admin', { failOnStatusCode: false });
    cy.get('[data-testid=forbidden]').should('be.visible');

    cy.signIn('admin');
    cy.visit('/admin');
    cy.get('[data-testid=nav-admin]').should('be.visible');

    cy.signIn('reader');
    cy.visit('/account');
    cy.get('[data-testid=nav-admin]').should('not.exist');
  });
});

describe('cy.session with a form login', () => {
  it('signs in through the UI once and restores it', () => {
    cy.signInWithForm('reader');
    cy.visit('/account');
    cy.get('[data-testid=nav-account]').should('be.visible');
  });

  it('reuses the cached form session', () => {
    cy.signInWithForm('reader');
    cy.visit('/account');
    cy.contains('h1', 'reader');
  });
});

describe('inspecting cached session data', () => {
  it('shows what cy.session stored', () => {
    cy.signIn('reader')
      .then(async () => {
        const data = await Cypress.session.getCurrentSessionData();
        const cookie = data.cookies.find((c) => c.name === 'bookshelf_session')!;
        expect(cookie.httpOnly).to.eq(true);
        expect(cookie.sameSite).to.eq('lax');
        expect(data.localStorage).to.deep.eq([]);
      });
    cy.then(() => Cypress.session.clearAllSavedSessions());
  });
});

It ran with the passwords passed as CYPRESS_ environment variables (more on that below). In the same run, the two beforeEach logins after the first one are restores, and the HttpOnly cookie is captured even though page scripts cannot read it:

export BASE_URL=http://localhost:3925
CYPRESS_READER_PASSWORD="$BOOKSHELF_READER_PASSWORD" CYPRESS_ADMIN_PASSWORD="$BOOKSHELF_ADMIN_PASSWORD" \
  npx cypress run --browser electron --spec cypress/e2e/session-api.cy.ts

  cy.session with a programmatic login
    ✓ starts signed in as the reader (127ms)
    ✓ restores the cached session in the next test (57ms)
    ✓ is blank right after cy.session() (90ms)

  switching roles inside one test
    ✓ moves between the reader and the admin without signing out (132ms)

  cy.session with a form login
    ✓ signs in through the UI once and restores it (437ms)
    ✓ reuses the cached form session (37ms)

  inspecting cached session data
    ✓ shows what cy.session stored (15ms)

  7 passing

The Command Log in cypress open marks each call as "new session", "restored saved session" or "recreated session" and shows an instrument panel with a "Clear All Sessions" button; these runs were headless, so that interface was not observed here, and the counter technique below is how to check caching in a run.

How long a session is cached

By default a session lives for the duration of the spec file; in cypress open it also survives reruns of the spec, which is what makes authoring fast, and "Clear All Sessions" or Cypress.session.clearAllSavedSessions() drops everything. A session cannot be modified after it is cached: create one with a new id instead. Setting cacheAcrossSpecs: true shares the session with every spec of the same cypress run on the same machine. The cache is in memory: a new run, or another CI machine in a parallel run, executes setup again.

Every spec that shares a session must call cy.session() with the same id, setup, validate and cacheAcrossSpecs value, otherwise Cypress throws instead of reusing it. Define the call once and import it. The shared helper below also counts its setup runs in the Node process with cy.task(), so the specs can assert that setup ran only once. The config registers three tasks (resetSetupCount, countSetup, setupCount) around a module-level counter; the helper is cypress/support/shared-session.ts:

// One definition, used by every spec that shares the session: the id, setup, validate and
// cacheAcrossSpecs value must be identical in all of them.
export const signInShared = () =>
  cy.session(
    'shared-reader',
    () => {
      cy.task('countSetup');
      cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
        cy.request({
          method: 'POST',
          url: '/api/session',
          body: { username: 'reader', password: READER_PASSWORD },
          log: false,
        });
      });
    },
    {
      validate() {
        cy.request('/api/session').then(({ body }) => body.authenticated);
      },
      cacheAcrossSpecs: true,
    },
  );

Two specs (shared-a.cy.ts and shared-b.cy.ts, identical apart from the title) use it after a first spec has reset the counter:

import { signInShared } from '../support/shared-session';

describe('spec a shares the cached session', () => {
  it('is signed in without running setup again', () => {
    signInShared();
    cy.visit('/account');
    cy.contains('h1', 'reader');
    // Setup ran once for the whole run: the Node-side counter is still at 1.
    cy.task('setupCount').should('eq', 1);
  });
});

Both specs passed with the counter at 1: the second spec restored the session the first one created.

Validating a session

A cached login can go stale: the server restarts, a token expires, or, in Bookshelf, a test calls /api/test/reset. Without validate, Cypress restores the cookie and the test starts "signed in" with a dead session. The spec below shows both behaviors with the same counter; validate is a function that yields false when the server no longer knows the session:

// Counts how many times the setup function really runs, in the Node process.
const login = (id: string, { validate }: { validate: boolean }) => {
  cy.session(
    [id, validate],
    () => {
      cy.task('countSetup');
      cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
        cy.request({
          method: 'POST',
          url: '/api/session',
          body: { username: 'reader', password: READER_PASSWORD },
          log: false,
        });
      });
    },
    validate
      ? {
          validate() {
            // Yielding false marks the session invalid at once; .should() would retry for the whole timeout.
            cy.request('/api/session').then(({ body }) => body.authenticated);
          },
        }
      : undefined,
  );
};

describe('validating a cached session', () => {
  before(() => {
    cy.task('resetSetupCount');
  });

  it('runs setup the first time', () => {
    login('validated', { validate: true });
    cy.task('setupCount').should('eq', 1);
  });

  it('does not run setup again while the session is valid', () => {
    login('validated', { validate: true });
    cy.task('setupCount').should('eq', 1);
  });

  it('re-runs setup when the server forgot the session', () => {
    // The reset endpoint clears every server-side session, so the cached cookie is stale.
    cy.request('POST', '/api/test/reset');
    login('validated', { validate: true });
    cy.task('setupCount').should('eq', 2);
  });
});

describe('without a validate function', () => {
  it('trusts a stale session and the protected page redirects', () => {
    login('unvalidated', { validate: false });
    cy.request('POST', '/api/test/reset');
    cy.visit('/admin');
    cy.location('pathname').should('eq', '/signin');
  });

  it('the cached cookie is restored but no longer works', () => {
    login('unvalidated', { validate: false });
    cy.visit('/account');
    cy.location('pathname').should('eq', '/signin');
  });
});

All five tests passed in Electron and in Chrome. The second group is the failure mode: the restored cookie is stale and the protected page redirects to /signin. One detail was measured while writing it: a first version validated with cy.request('/api/session').its('body.authenticated').should('eq', true). When the session was stale, the failing assertion retried until the default 4000 ms command timeout before setup ran again (the test took about 4 seconds). Making the last command yield false, as above, marks the session invalid immediately (the same test took 59 ms). Use whichever fits: a failing .should() is the clearest message for a login that must work, a yielded false is faster for a check whose purpose is to trigger a re-login.

Other validate shapes from the documentation: visit a protected page and let cy.visit() fail on a 4xx status, request an API that answers 200 only when signed in, or run any command that fails when the user is anonymous. The documentation also warns that 401 errors straight after cy.session() usually mean the session was not fully established when it was saved; asserting a post-login signal (URL, element, cookie or localStorage token) at the end of setup fixes that.

Common mistakes with cy.session()

  • Forgetting cy.visit(). With testIsolation on, the page after cy.session() is about:blank; every command fails until the test visits something.

  • Ids that collide. cy.session(name, ...) where the login also depends on a token or password restores the first session for every variation. Build the id from every changing parameter.

  • Secrets in the id. The id is displayed in the Command Log.

  • No validate. The session restores happily after the server forgot it.

  • Calling cy.visit() in the helper. A second cy.visit() in the test then doubles the page load; call the helper in a beforeEach and visit once.

  • Mismatched definitions with cacheAcrossSpecs. Copy-pasted cy.session() calls drift; share one helper.

  • Expecting the helper to return a value. cy.session() yields null; assert in setup.

  • Shared server-side state. A cached login reused by tests that mutate the account is only as isolated as the account. Bookshelf’s reset endpoint is fine for serial runs, and validate repairs the cached session after it.

  • testIsolation: false. The page is then not cleared, and the documentation recommends signing in in a before hook so the suite starts from a known state.

Programmatic login

A programmatic login talks to the backend instead of driving the login page. In Cypress that is cy.request(): the browser stores cookies from its response, so a cookie-session app like Bookshelf needs nothing more (the support file above is a complete example). Token-based apps need one more step, writing the token where the app reads it, typically localStorage:

cy.session(username, () => {
  cy.request({ method: 'POST', url: '/login', body: { username, password }, log: false })
    .then(({ body }) => {
      window.localStorage.setItem('authToken', body.token);
    });
});

This snippet is the documentation’s pattern for an unspecified backend; it was not run because Bookshelf uses cookies. Practical points:

  • Pass log: false to cy.request() and .type() calls that carry credentials, and read the credentials with cy.env().

  • Programmatic login is only valid if the login page is covered somewhere else, with a form login in a dedicated spec.

  • Programmatic login against an identity provider needs the application to cooperate (for example a configuration flag that accepts a token minted by the test), which is why the provider guides below show adapting the app.

Identity provider guides

The official documentation has one guide per provider. They share one structure: configure the provider with a test tenant and a dedicated test user, put the credentials in env (secrets) and the public identifiers in expose in the Cypress configuration, write one or two custom commands, then wrap the command in cy.session() with a validation that checks for the token the app stores. None of the guides could be run here because each needs an account with the provider, so the table summarizes them from the documentation.

Provider Approach in the guide Notes

Auth0

loginToAuth0 signs in through the Universal Login page with cy.origin() on the tenant domain, then wraps it in cy.session(). loginByAuth0Api calls the /oauth/token endpoint with the password grant and stores the tokens in localStorage.

Written for the Classic Universal Login experience and a dedicated test tenant. The password grant and default directory must be enabled in the tenant. The client secret is only needed for the programmatic command.

Okta

loginByOkta uses cy.origin() against the Okta domain; loginByOktaApi uses the Okta Authentication API and the Auth SDK to obtain the id token and stores it.

Scope is the Okta Universal Directory. The programmatic path needs the app adapted to read the token (front end and a JWT verifier on the back end).

Amazon Cognito

Programmatic only: the AWS Amplify auth library signs the user in (signIn with a user pool) and the command stores the resulting session in localStorage inside cy.session().

The guide is shown for Amplify v5 and v6; the user-password auth flow must be enabled on the app client.

Microsoft Entra ID (Azure Active Directory)

loginToAAD clicks the app’s sign-in button, then uses cy.origin() on login.microsoftonline.com (and live.com for some accounts) to submit the credentials, and caches the result with cy.session().

Requires experimentalModifyObstructiveThirdPartyCode. The guide’s sample removes integrity attributes and uses the redirect flow because pop-ups do not work inside Cypress. The guide still uses the old product name, Azure Active Directory.

Google

Programmatic: a refresh token created once in the Google OAuth 2.0 Playground is exchanged for an access token, the profile is fetched and a localStorage item is set.

Needs an OAuth client and a consent screen with the test account listed. The refresh token is per user.

Social providers (Facebook, Google, Microsoft through Auth0)

One function per provider drives the provider’s own login page with cy.origin(), then cy.session() caches the result.

The documentation does not recommend this in CI: bot detection can block the account and the terms of service may forbid it. Requires experimentalModifyObstructiveThirdPartyCode.

A condensed command in the shape those guides use, adapted to read the secret with cy.env() and the public domain with Cypress.expose() (the 16 form of the guides; it was not run):

Cypress.Commands.add('loginToProvider', (username: string) => {
  cy.session(
    `provider-${username}`,
    () => {
      cy.visit('/'); // the app redirects anonymous users to the provider
      cy.env(['PROVIDER_PASSWORD']).then(({ PROVIDER_PASSWORD }) => {
        cy.origin(
          Cypress.expose('providerDomain'),
          { args: { username, password: PROVIDER_PASSWORD } },
          ({ username, password }) => {
            cy.get('input#username').type(username);
            cy.get('input#password').type(password, { log: false });
            cy.contains('button', 'Continue').click();
          },
        );
      });
      // Back on the app origin after the redirect.
      cy.location('pathname').should('eq', '/');
    },
    {
      validate() {
        cy.window().its('localStorage.accessToken').should('exist');
      },
    },
  );
});

cy.origin() and cross-origin testing

Browsers apply the same-origin policy, and Cypress runs inside the browser, so it can only drive the page of the origin it started on. An origin is scheme, host and port: https://www.cypress.io, http://www.cypress.io, https://docs.cypress.io and https://www.cypress.io:81 are four different origins. A test can visit different origins in different tests with no help, but within one test every later command must run through cy.origin() as soon as the page is on another origin. Typical triggers are an <a> or JavaScript redirect to another host and a form post that the server redirects elsewhere, which is exactly what single sign-on does.

Cypress 14.0.0 made this stricter: document.domain is no longer injected, so cy.origin() is needed between any two origins, not only between different superdomains (previously docs.cypress.io and www.cypress.io were treated as one). The injectDocumentDomain option restores the old behavior, is deprecated and is documented to cause trouble with sites that send Origin-Agent-Cluster (Azure AD B2C and Salesforce are listed). It must be true for the experimental WebKit support, where cy.origin() is not implemented yet.

cy.origin(url, callbackFn)
cy.origin(url, options, callbackFn)
Part Meaning

url

The secondary origin: at least a hostname, optionally scheme and port. The hostname must match exactly, including subdomains. Defaults to https when no scheme is given. Query strings are not supported. Inside the callback it also replaces baseUrl, so cy.visit('/path') and cy.request('/path') resolve against it.

options.args

The only way to pass data into the callback. A plain, serializable object (structured clone); the callback is not a closure. Class instances arrive without their methods.

callbackFn

Stringified, sent to a second Cypress instance that Cypress injects into the secondary origin, and evaluated there with args as its only argument.

Yield

The value yielded by the last command in the callback, or the function’s return value. It must be serializable: yielding an element fails, yielding .invoke('text') works.

Restrictions, from the documentation: callbacks cannot contain cy.origin(), cy.intercept() or cy.session() (visit several origins one after another at the top level of the test); cy.origin() cannot drive another window or tab, and it cannot reach into a cross-origin <iframe>. For code sharing, Cypress.require() loads npm packages and local files inside the callback, and needs experimentalOriginDependencies: true; plain CommonJS require() and dynamic import() do not work there. Custom commands can be loaded into an origin once with Cypress.require('../support/commands'), and the JavaScript context of an origin persists between cy.origin() calls to the same origin in the same test. cy.env() works inside the callback, so a secret can be read there instead of being passed through args.

Running it on Bookshelf

Bookshelf answers on localhost and on 127.0.0.1; the browser treats these as two unrelated origins with separate cookies. That makes 127.0.0.1 a convenient "secondary origin" without needing an external service. The config sets experimentalOriginDependencies: true. This is the real origin.cy.ts:

// localhost and 127.0.0.1 serve the same Bookshelf but are different origins, so each has its own cookies.
const secondary = Cypress.config('baseUrl')!.replace('localhost', '127.0.0.1');

describe('cy.origin with a second origin', () => {
  it('signs in on the secondary origin only', () => {
    cy.visit('/');
    cy.get('[data-testid=nav-signin]').should('be.visible');

    cy.origin(secondary, { args: { username: 'reader' } }, ({ username }) => {
      // Inside the callback baseUrl is the secondary origin, and the callback is not a closure.
      cy.visit('/signin');
      cy.get('[data-testid=username]').type(username);
      cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
        cy.get('[data-testid=password]').type(READER_PASSWORD, { log: false });
      });
      cy.get('[data-testid=signin-submit]').click();
      cy.location('pathname').should('eq', '/account');
      cy.get('[data-testid=nav-account]').should('be.visible');
    });

    // Back in the primary origin the visitor is still anonymous: cookies are per origin.
    cy.visit('/');
    cy.get('[data-testid=nav-signin]').should('be.visible');
  });

  it('yields only serializable values', () => {
    cy.origin(secondary, () => {
      cy.visit('/books');
      cy.get('[data-testid=result-count]').invoke('text');
    }).should('match', /24 books/);
  });

  it('shares code with Cypress.require()', () => {
    cy.origin(secondary, { args: { username: 'reader' } }, ({ username }) => {
      const { SigninPage } = Cypress.require('../support/signin-page');
      const page = new SigninPage();
      cy.visit('/signin');
      page.fillUsername(username);
      cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => page.fillPassword(READER_PASSWORD));
      page.submit();
      cy.location('pathname').should('eq', '/account');
    });
  });

  it('reads a value only through args', () => {
    const outside = 'not visible inside';
    cy.origin(secondary, { args: { outside } }, ({ outside }) => {
      expect(outside).to.eq('not visible inside');
    });
  });
});

describe('cy.session around cy.origin', () => {
  it('caches a login that happened on another origin', () => {
    cy.session(
      'secondary-reader',
      () => {
        cy.origin(secondary, () => {
          cy.visit('/signin');
          cy.get('[data-testid=username]').type('reader');
          cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
            cy.get('[data-testid=password]').type(READER_PASSWORD, { log: false });
          });
          cy.get('[data-testid=signin-submit]').click();
          cy.location('pathname').should('eq', '/account');
        });
      },
      {
        validate() {
          // cy.request is not bound by the same-origin policy, so it can check the other origin.
          cy.request(`${secondary}/api/session`).its('body.authenticated').should('eq', true);
        },
      },
    );
    // Visiting the secondary origin first makes it the primary one for the rest of the test,
    // so no cy.origin() block is needed (and one would be rejected).
    cy.visit(`${secondary}/account`);
    cy.get('[data-testid=nav-account]').should('be.visible');
  });
});

The page object used in the third test is a plain class, cypress/support/signin-page.ts, imported inside the callback because a class cannot cross the args boundary:

export class SigninPage {
  fillUsername(username: string) {
    cy.get('[data-testid=username]').type(username);
  }

  fillPassword(password: string) {
    cy.get('[data-testid=password]').type(password, { log: false });
  }

  submit() {
    cy.get('[data-testid=signin-submit]').click();
  }
}

All five tests passed in Electron and Chrome. Two error messages are worth recognizing.

Without cy.origin(), a second cy.visit() to another origin succeeds and the next command fails after the timeout. A throwaway test that visited / and then http://127.0.0.1:3925/books and queried the book cards failed with:

CypressError: Timed out retrying after 4000ms: The command was expected to run against origin `http://localhost:3925`
but the application is at origin `http://127.0.0.1:3925`.
This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly.
Use `cy.origin()` to wrap the commands run on `http://127.0.0.1:3925`.

After cy.visit() has moved the test onto the secondary origin, that origin is already the primary one, and wrapping commands for it in cy.origin() is an error:

CypressError: `cy.origin()` requires the first argument to be a different origin than top. You passed
`http://127.0.0.1:3925` to the origin command, while top is at `http://127.0.0.1:3925`.

When cy.origin() is not enough

The cross-origin guide lists the options in order of preference:

  • Do not visit sites you do not control. Assert the href of the link (cy.get('a').should('have.attr', 'href', url)) instead of following it, which is faster and deterministic.

  • cy.request() is not bound by CORS or the same-origin policy; use it to check an external URL’s response or to POST to a single sign-on server and pick the token out of the redirect.

  • Programmatic login skips the redirect dance completely.

  • experimentalModifyObstructiveThirdPartyCode: true applies Cypress’s framebusting removal to third-party scripts and pages that are loaded or navigated to, adjusts the Electron user agent, removes Subresource Integrity from the modified scripts and fixes the Sec-Fetch-Dest header. Use it when a provider’s page breaks only under Cypress; the Entra ID and social login guides require it. removeSRIAttributes removes integrity attributes from first-party resources too.

  • chromeWebSecurity: false (Chrome-family browsers only; it has no effect in Firefox or others, and Cypress logs a warning) shows insecure content, allows navigating anywhere without cross-origin errors and lets Cypress reach cross-origin iframes. A suite that relies on it cannot run in other browsers.

Two limits remain even with these: cross-origin iframes are not supported without chromeWebSecurity: false, and an HTTPS page that navigates to an HTTP URL errors, because that would send cookies without the secure flag in clear text; fix the link or mark cookies secure rather than disabling web security.

Environment variables in Cypress 16: cy.env() and Cypress.expose()

Before 16, Cypress.env() was the one place for configuration values, and it serialized every variable into the browser, where application code, third-party scripts and extensions could read them; with cy.origin() they were also sent into the secondary origin. Cypress 15.10 deprecated it, and Cypress 16.0.0 removed it. Its two replacements split by sensitivity:

cy.env() Cypress.expose()

Use for

API keys, passwords, tokens, credentials

Feature flags, API versions, environment labels, public URLs, plugin configuration

Access

Asynchronous command: cy.env(['key']).then(...)

Synchronous function: Cypress.expose('key')

Where values live

In the Node process; only the requested keys are handed to the test

In the browser: visible to application code, third-party scripts and extensions

Configured under

env (config file, cypress.env.json, CYPRESS_* variables, --env, setupNodeEvents)

expose (config file, --expose, test configuration)

Works inside cy.origin()

Yes, read the secret inside the callback

Pass the value through args, or read it inside the callback with Cypress.expose()

Setting values

Source Notes

env key in the config file

env: { apiKey: process.env.API_KEY } reads from the OS environment at startup.

cypress.env.json

In the project root; overrides conflicting config-file values. Add it to .gitignore if it holds secrets.

CYPRESS_* shell variables

CYPRESS_FOO=1 becomes FOO. CYPRESS_RECORD_KEY and CYPRESS_PROJECT_ID are consumed by the CLI for Cypress Cloud, not test variables, and CYPRESS_INTERNAL_ENV is reserved.

--env host=a,port=1

Comma-separated. Pass objects as JSON: --env credentials='{"user":"jane"}'.

setupNodeEvents

config.env.apiKey = process.env.API_KEY; return config.

expose key in the config file

Public values for Cypress.expose().

--expose a=1,b=2 (or -x)

CLI values override the config file.

Test configuration

describe('x', { expose: { flow: 'guest' } }, ...). Overridden keys are restored after each test. The env key is no longer valid in test configuration.

cy.env()

cy.env(keys)
cy.env(keys, options)

keys is a non-empty array of strings. The command yields an object with the keys that were found; a key that is not set reads as undefined, so destructuring defaults work. options.log (default true) controls the Command Log entry, which shows key names and never values. options.timeout defaults to 4000 ms and does not follow defaultCommandTimeout; raise it on a slow CI machine. cy.env() is read-only.

The protection ends where the command yields. The object is an ordinary JavaScript object, and Cypress does not redact it. The documentation lists what leaks a value into the Command Log and console: assertions on the secret (every assertion prints the compared values), .its() and .invoke() on the yielded object, and any failing chained command. So:

  • assert on a derived boolean (expect(Boolean(apiKey)).to.be.true), not on the value;

  • read properties inside .then() (which adds no log entry) instead of .its();

  • pass { log: false } to the commands that receive the value (cy.request(), .type()), remembering that this hides the entry but does not redact the value.

The real spec below exercises those rules, together with Cypress.expose() and its test configuration. Passwords reach it as CYPRESS_READER_PASSWORD in the shell; the config file declares the public values:

import { defineConfig } from 'cypress';

let setupRuns = 0;

export default defineConfig({
  // Public values, readable synchronously with Cypress.expose().
  expose: { environment: 'local', apiVersion: 'v1' },
  e2e: {
    baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
    video: false,
    experimentalOriginDependencies: true,
    setupNodeEvents(on) {
      // A Node-side counter lets specs observe how many times a session setup really ran.
      on('task', {
        resetSetupCount() {
          setupRuns = 0;
          return null;
        },
        countSetup() {
          return ++setupRuns;
        },
        setupCount() {
          return setupRuns;
        },
      });
    },
  },
});

The spec, env-and-expose.cy.ts:

describe('secrets with cy.env()', () => {
  it('reads only the keys it is asked for', () => {
    cy.env(['READER_PASSWORD']).then((env) => {
      // Assert on a derived boolean, never on the secret itself.
      expect(Boolean(env.READER_PASSWORD)).to.be.true;
      expect(Object.keys(env)).to.deep.eq(['READER_PASSWORD']);
    });
  });

  it('yields undefined for a key that is not set', () => {
    cy.env(['NOT_DEFINED']).then(({ NOT_DEFINED = 'fallback' }) => {
      expect(NOT_DEFINED).to.eq('fallback');
    });
  });

  it('accepts a timeout and a log option', () => {
    cy.env(['READER_PASSWORD'], { log: false, timeout: 10000 }).then(({ READER_PASSWORD }) => {
      cy.request({
        method: 'POST',
        url: '/api/session',
        body: { username: 'reader', password: READER_PASSWORD },
        log: false,
      })
        .its('status')
        .should('eq', 200);
    });
  });

  it('keeps secrets out of the page under test', () => {
    cy.visit('/');
    cy.window().then((win: any) => {
      // Page scripts can see window.Cypress and everything exposed, but no secret.
      expect(win.Cypress.expose('environment')).to.eq('local');
      expect(JSON.stringify(win.Cypress.expose())).not.to.contain('READER_PASSWORD');
      expect(() => win.Cypress.env('READER_PASSWORD')).to.throw('was removed');
    });
  });
});

describe('public values with Cypress.expose()', () => {
  it('reads values from the configuration file', () => {
    expect(Cypress.expose('environment')).to.eq('local');
    expect(Cypress.expose('apiVersion')).to.eq('v1');
    expect(Cypress.expose('missing')).to.be.undefined;
    expect(Cypress.expose()).to.include({ environment: 'local' });
  });

  it('changes values for the rest of the spec file', () => {
    Cypress.expose('environment', 'changed');
    expect(Cypress.expose('environment')).to.eq('changed');
    Cypress.expose({ environment: 'local', extra: true });
  });

  describe('with test configuration', { expose: { flow: 'guest' } }, () => {
    it('applies the suite value', () => {
      expect(Cypress.expose('flow')).to.eq('guest');
    });

    it('merges the test value', { expose: { step: 'payment' } }, () => {
      expect(Cypress.expose('flow')).to.eq('guest');
      expect(Cypress.expose('step')).to.eq('payment');
    });
  });

  it('restores overridden keys after the test', () => {
    expect(Cypress.expose('flow')).to.be.undefined;
  });
});

describe('Cypress.env() after the removal', () => {
  it('throws instead of returning a value', () => {
    // @ts-expect-error Cypress.env() was removed from the types in Cypress 16
    expect(() => Cypress.env('READER_PASSWORD')).to.throw('was removed in Cypress version 16.0.0');
  });
});

All ten tests passed in Electron and in Chrome. The last test shows the reality of the removal: Cypress.env is still a function at runtime in 16.2.0, but calling it throws a CypressError whose message says that Cypress.env() was removed in Cypress version 16.0.0, tells you to use Cypress.expose() for non-sensitive values or cy.env() for sensitive ones, names the key being read and points at plugins that may still call it. The TypeScript types no longer declare the method, and the TypeScript types no longer declare it. The fourth test shows the point of the change: page code can call window.Cypress.expose() and reads public values, but a secret is not there.

Where each source ends up

This spec checks every way of setting a value in one run. It was run with --env FROM_FLAG=cli --expose flagValue=cli, with CYPRESS_FROM_SHELL=shell in the shell and a cypress.env.json containing { "FROM_FILE": "file" }:

// Run with: --env FROM_FLAG=cli --expose flagValue=cli
// plus the shell variable CYPRESS_FROM_SHELL=shell and a cypress.env.json containing FROM_FILE.
it('reads each source', () => {
  cy.env(['FROM_FLAG', 'FROM_SHELL', 'FROM_FILE']).then((env) => {
    expect(env).to.deep.eq({ FROM_FLAG: 'cli', FROM_SHELL: 'shell', FROM_FILE: 'file' });
  });
  // --expose merges into the exposed values of the configuration file.
  expect(Cypress.expose('flagValue')).to.eq('cli');
  expect(Cypress.expose('environment')).to.eq('local');
  // Shell variables prefixed with CYPRESS_ feed cy.env(), not Cypress.expose().
  expect(Cypress.expose('FROM_SHELL')).to.be.undefined;
});

It passed. One difference between the documentation and the observed behavior is worth knowing: --expose merges into the config file’s expose object ({"environment":"local","apiVersion":"v1","flagValue":"cli"}, and a value like other=2 is parsed as a number), but a CYPRESS_expose shell variable holding a JSON object replaced the whole exposed object on 16.2.0 ({"fromJson":1}; the config file’s values disappeared). Prefer --expose when you only want to add or override keys. Cypress warns when CYPRESS_env or CYPRESS_expose holds something that is not a JSON object.

In CI, supply secrets from the provider’s secret store as CYPRESS_ variables on the job (this was not run here, the pattern is the standard env: block of a workflow step):

- name: Cypress run
  run: npx cypress run --browser chrome
  env:
    CYPRESS_READER_PASSWORD: ${{ secrets.BOOKSHELF_READER_PASSWORD }}
    CYPRESS_ADMIN_PASSWORD: ${{ secrets.BOOKSHELF_ADMIN_PASSWORD }}

Migrating from Cypress.env()

Before (15 and earlier) After (16) Notes

Cypress.env('apiKey') for a secret

cy.env(['apiKey']).then(({ apiKey }) => ...)

Asynchronous: the code that uses the value moves inside .then().

Cypress.env('API_VERSION') for a public value

Cypress.expose('API_VERSION')

Synchronous. Move the key from env to expose in the config.

A plain helper such as authHeaders() that returned a secret

A custom command that reads cy.env() and issues the request in the same chain

A helper cannot return a secret any more because cy.env() is a command.

it('x', { env: { FLAG: true } }, ...)

it('x', { expose: { FLAG: true } }, ...)

The env key is no longer valid in test and suite configuration overrides, because the override runs in the browser.

allowCypressEnv: true in the config

Delete the line

The option was removed; Cypress warns while it remains.

A plugin that reads Cypress.env()

Update the plugin

The error message points at plugins explicitly.

A reusable command in the shape recommended by the migration guide:

Cypress.Commands.add('apiRequest', (options) => {
  return cy.env(['apiKey']).then(({ apiKey }) => {
    return cy.request({
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${apiKey}` },
    });
  });
});

This snippet is the guide’s own example, adapted to TypeScript style; it was not run. The signIn command at the top of this page is the runnable equivalent.

Failing fast when a secret is missing

A missing variable otherwise appears as an unrelated 401 later. A before hook in the support file can check once. The documentation recommends this pattern; this example was not run:

before(() => {
  cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
    if (!READER_PASSWORD) {
      throw new Error('READER_PASSWORD is not set. Add it to cypress.env.json or set CYPRESS_READER_PASSWORD.');
    }
  });
});

Client certificates (mutual TLS)

Some environments require the client to present a certificate during the TLS handshake, before any application code runs. Configuring clientCertificates lets Cypress present it, so the tests exercise the real security boundary. The setting is an array of entries:

Property Type Description

url

string

URL to match, with minimatch wildcards (https://b.host.com/base/**).

ca

string array, optional

Paths to CA files to validate certificates against, relative to the project root.

certs

object array

Each item is a PEM pair (cert, key, optional passphrase) or a PFX container (pfx, optional passphrase). The passphrase is the path of a text file holding it. RSA and ECDSA keys are supported.

Cypress picks the entry for each outgoing request by URL, including cy.visit() and cy.request(), and no options are needed on the commands. If several entries match, the most specific (longest) path pattern wins. It is not possible to choose a certificate per call: to act as different identities against one base URL, use distinct path patterns or separate configuration files per identity. Typical symptoms are a 400 Bad Request or a handshake error when the pattern does not match the full URL, including a non-default port; a bad decrypt error when the passphrase file has trailing whitespace; and ENOENT when a path is wrong, often because the certificate files are not present in CI. In CI, store the material as secrets, write it to disk at the start of the job and keep the directory out of version control.

Trying it

A small Node HTTPS server (https-server.js) asks for a client certificate and answers /secure with a greeting for the certificate’s common name, or 401 without one. The certificates were generated with OpenSSL (LibreSSL 3.3.6 on macOS): a test CA, a server certificate for localhost with a subject alternative name, and a client certificate whose private key is encrypted with a passphrase kept in a text file:

openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 30 -subj "/CN=Bookshelf Test CA"

openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost"
printf "subjectAltName=DNS:localhost,IP:127.0.0.1\nbasicConstraints=CA:FALSE\n" > server.ext
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.pem -days 30 -extfile server.ext

openssl req -newkey rsa:2048 -nodes -keyout client.plain.key -out client.csr -subj "/CN=alice"
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out client.pem -days 30
printf 'test-only-passphrase' > client-passphrase.txt
openssl rsa -in client.plain.key -aes256 -passout file:client-passphrase.txt -out client.key

curl -s --cacert ca.pem https://localhost:39250/secure -w ' [%{http_code}]\n'
# client certificate required [401]
curl -s --cacert ca.pem --cert client.pem --key client.key --pass "$(cat client-passphrase.txt)" \
  https://localhost:39250/secure -w ' [%{http_code}]\n'
# <h1 id="who">Hello alice</h1> [200]

The throwaway CA and keys exist only for this experiment; the passphrase above is a test value generated for it, not a real secret. The Cypress configuration and spec:

import { defineConfig } from 'cypress';

const trusted = process.env.TRUST_CERT === 'true';

export default defineConfig({
  // Present the client certificate to the protected host (mutual TLS). The URL must cover the
  // whole origin: a path pattern is rejected on the native browser network path.
  clientCertificates: [
    {
      url: 'https://localhost:39250',
      ca: ['certs/ca.pem'],
      certs: [
        {
          cert: 'certs/client.pem',
          key: 'certs/client.key',
          passphrase: 'certs/client-passphrase.txt',
        },
      ],
    },
  ],
  // Tell Chromium to trust the certificate the server presents (native network path only).
  trustedCertificates: trusted ? [{ filePath: 'certs/server.pem' }] : [],
  e2e: { video: false, supportFile: false },
});

The spec:

const secure = 'https://localhost:39250';

describe('mutual TLS with clientCertificates', () => {
  it('cy.request presents the client certificate', () => {
    cy.request(`${secure}/secure`).then((response) => {
      expect(response.status).to.eq(200);
      expect(response.body).to.contain('Hello alice');
    });
  });

  it('cy.visit presents the client certificate too', () => {
    cy.visit(`${secure}/secure`);
    cy.get('#who').should('have.text', 'Hello alice');
  });

  it('a URL outside the pattern gets no certificate', () => {
    cy.request({ url: `${secure.replace('localhost', '127.0.0.1')}/secure`, failOnStatusCode: false })
      .its('status')
      .should('eq', 401);
  });
});

All three tests passed in Electron and in Chrome. With the clientCertificates array removed, the first two tests failed with cy.request() failed ... 401: Unauthorized, which is the symptom of a certificate that was not presented.

One behavior is not in the documentation page. With the entry url: 'https://localhost:39250/**', Electron (the legacy network path) accepted it, but Chrome (the native browser network path used since Cypress 16) refused to run the spec:

Cypress could not apply your clientCertificates configuration.
The certificate for localhost:39250 is limited to part of that origin:
 - https://localhost:39250/**
A browser presents a client certificate when it connects to an origin, before any path is known, so Cypress cannot keep it to
the configured path. Configure the certificate for the whole origin by removing the path from the URL.
Setting forceHttp1: true routes this run through Cypress's HTTP/1 proxy, which makes the request itself and so can honor
per-path entries, at the cost of HTTP/2.

So on Chrome, Chromium and Edge, configure client certificates per origin (no path); per-path entries, such as one identity per /users/alice/**, need forceHttp1: true (deprecated, and a temporary escape hatch) or separate configuration files.

trustedCertificates (16.1)

Cypress launches Chromium-based browsers with --ignore-certificate-errors, so an HTTPS site that uses a self-signed or private-CA certificate loads. Chromium treats such an override like a clicked-through warning and does not write responses from the origin to its HTTP disk cache, so asset-heavy specs download every asset again on each cy.visit(). trustedCertificates (new in 16.1.0) lists certificates the browser should genuinely trust: Cypress adds their SubjectPublicKeyInfo (SPKI) fingerprints to --ignore-certificate-errors-spki-list, and the blanket flag stays, so undeclared origins behave as before. It applies only to Chrome, Chromium and Edge on the native browser network path; it does nothing in Firefox, WebKit, Electron or with forceHttp1: true. It is not a way to present certificates: use clientCertificates for that.

Each array entry must have exactly one of these keys:

Key Type Meaning

filePath

string

PEM file; a relative path resolves against the project root.

pem

string

Inline PEM text.

spki

string

Precomputed base64 SHA-256 of the SPKI, 44 characters ending in =.

All certificates in a PEM file or pem string are trusted, so a bundle with a leaf and its CA is one entry. Chromium matches the fingerprint against any certificate in the chain the server sends: declare the CA only if the server presents the chain, otherwise declare the leaf. Changing the setting restarts the browser (not the Cypress server). The fingerprint of a certificate is computed with:

openssl x509 -in server.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
# Jvp+MF7kFXtXdD8VNqWPuv1lajWmFRJWLigga2NLe+o=

Verification on Chrome 155 with the server certificate above and trustedCertificates: [{ filePath: 'certs/server.pem' }]:

  • The Chrome process was started with --ignore-certificate-errors and --ignore-certificate-errors-spki-list=Jvp+MF7kFXtXdD8VNqWPuv1lajWmFRJWLigga2NLe+o=, the same fingerprint that OpenSSL printed for the certificate (read from the process arguments while a spec was running).

  • The three kinds of invalid entry are rejected as documented: a short spki ("Expected trustedCertificates[0].spki to be a base64-encoded SHA-256 SPKI fingerprint"), an entry with both filePath and spki ("Expected trustedCertificates[0] to be an object with exactly one of "filePath", "pem", or "spki""), and a missing file, which fails at browser launch with an error naming the entry ("Cypress could not load a certificate you listed in trustedCertificates"). An spki entry with the correct fingerprint was accepted.

  • The caching benefit could not be reproduced in this setup. A page with a script served with Cache-Control: max-age=3600 was requested once with and without trustedCertificates, whether the three visits were in one test or in three spec files, so the browser’s in-memory cache or Cypress’s own handling hid the difference; the documented effect on the disk cache was not demonstrated here.

Proxy configuration

A corporate proxy matters at two separate moments. At installation, npm install or cypress install downloads the binary. When running tests, the Cypress app loads the application and talks to Cypress Cloud. Without proxy settings Cypress cannot load pages other than localhost, cannot warn that baseUrl is unreachable and cannot reach Cypress Cloud.

  • Standard HTTP and HTTPS proxies are supported. PAC files and SOCKS proxies are not: ask for the HTTP proxy, or put a local HTTP proxy in front of the SOCKS one.

  • Set HTTP_PROXY (used for HTTP and HTTPS traffic) and optionally HTTPS_PROXY. A falsy HTTP_PROXY (false, 0, empty) turns the proxy off. If both an uppercase and a lowercase variable exist, the lowercase one wins.

  • NO_PROXY is a comma-separated bypass list. Loopback (127.0.0.1, ::1, localhost) is always appended to it, so loopback traffic is never proxied. The only way to force loopback traffic through a proxy is to include the special value <-loopback> in NO_PROXY.

  • Resolution order differs by phase:

Phase Proxy resolution (first match wins) Custom CA sources

Installation

HTTP_PROXY / HTTPS_PROXY

npm cafile, ca

Running tests

HTTP_PROXY / HTTPS_PROXY, then npm proxy / https-proxy, then the Windows registry

npm cafile, ca, NODE_EXTRA_CA_CERTS

npm’s noproxy setting is not applied to NO_PROXY. NODE_EXTRA_CA_CERTS and the Windows registry are not read during installation, so a TLS-intercepting proxy with a custom CA needs cafile or ca in .npmrc for the binary download. At run time cafile or ca replace the trusted CAs and NODE_EXTRA_CA_CERTS extends them. The documentation says npx cypress info prints the detected proxy variables and that the Settings tab of the app shows the proxy in use; on 16.2.0 cypress info printed only the browsers, the cache path and the version in this environment, with no proxy section, so confirm the proxy by watching the proxy’s own log, as below.

Observing the proxy

A 40-line Node forward proxy (proxy.js, listening on port 39251) logs every request and CONNECT tunnel. The spec makes one cy.request() and one cy.visit() of the catalogue:

it('sends the test traffic through the configured proxy', () => {
  cy.request('/api/books?q=quiet').its('body.total').should('eq', 1);
  cy.visit('/books');
  cy.get('[data-testid=book-card]').should('have.length', 6);
});

Running it with and without the proxy settings:

# Default NO_PROXY: loopback is never proxied, so the proxy log did not change (94 entries before and after the run).
HTTP_PROXY=http://localhost:39251 BASE_URL=http://localhost:3925 npx cypress run --browser electron

# Force loopback through the proxy:
HTTP_PROXY=http://localhost:39251 NO_PROXY='<-loopback>' BASE_URL=http://localhost:3925 npx cypress run --browser electron
#   1 passing
curl -s localhost:39251/__log
# ["GET http://localhost:3925/", "GET http://localhost:3925/api/books?q=quiet", "GET http://localhost:3925/books",
#  "GET http://localhost:3925/app.css", "GET http://localhost:3925/app.js", "GET http://localhost:3925/covers/1.svg", ...]

With <-loopback> the run passed in Electron and in Chrome and the proxy saw the application’s traffic. In Chrome the whole browser used the proxy, so its log also contained Cypress’s own runner assets on 127.0.0.1 and background Google requests (update checks, autofill); expect that noise in a real proxy log, and add the hosts it should not carry to NO_PROXY.

Pitfalls summary

  • Stale cached login. Always add validate, especially if anything resets server-side sessions.

  • Id collisions and secrets in ids. One id per distinct session, built from every parameter, never from a password.

  • Blank page after cy.session(). Visit after it.

  • cy.origin() assumptions. The callback is not a closure, cy.origin() cannot be nested, and after a cy.visit() to another origin that origin is already the primary one.

  • Secrets that leak after cy.env(). Assertions, .its() and failing commands print the value; derive a boolean and use log: false.

  • Putting a secret in Cypress.expose(). It is visible to the page.

  • Moving Cypress.env() calls mechanically. A secret read in a plain helper must become a command.

  • Per-path client certificates on Chrome. Use origin-level entries.

  • Assuming trustedCertificates helps Firefox, WebKit or Electron. It does not apply there.

  • Loopback behind a proxy. It is bypassed unless <-loopback> is in NO_PROXY.

What changed recently

  • 12.0.0: cy.session() and cy.origin() are always available (the experimentalSessionAndOrigin flag was removed).

  • 14.0.0: document.domain is no longer injected, so cy.origin() is required between any two origins in a test; injectDocumentDomain restores the old behavior and is deprecated.

  • 15.10.0: Cypress.env() deprecated; cy.env() and Cypress.expose() introduced.

  • 15.16.0: ECDSA keys supported in clientCertificates.

  • 16.0.0 (1 September 2026): Cypress.env() removed, the env key removed from test and suite configuration overrides and allowCypressEnv removed; Chrome, Chromium and Edge use the native browser network (which is why client certificates cannot be limited to a path there; forceHttp1: true is the deprecated escape hatch); the Electron browser is deprecated.

  • 16.1.0 (15 September 2026): the trustedCertificates option.

  • 16.2.0 (13 October 2026): no change to these features; the version used for the examples on this page.