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 ( |
Cookies set by the response are stored in the browser automatically; tokens go to |
Fast, but still one request per test, and the login page is not covered. |
Cache the login with |
Runs the login once, then restores cookies, |
Needs a correct session id and a |
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)
Source: Cypress: cy.session()
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 |
|---|---|
|
String, array or object. Arrays and objects are serialized deterministically ( |
|
Function that performs the login. It runs when no session is cached for the id, or when a restored session fails validation.
Cookies, |
|
Function that checks the session right after |
|
Default |
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 {};
Source: Cypress: cy.session()
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());
});
});
Source: Cypress: Cypress.session
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
Source: Cypress: cy.session()
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(). WithtestIsolationon, the page aftercy.session()isabout: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 secondcy.visit()in the test then doubles the page load; call the helper in abeforeEachand visit once. -
Mismatched definitions with
cacheAcrossSpecs. Copy-pastedcy.session()calls drift; share one helper. -
Expecting the helper to return a value.
cy.session()yieldsnull; assert insetup. -
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
validaterepairs the cached session after it. -
testIsolation: false. The page is then not cleared, and the documentation recommends signing in in abeforehook 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);
});
});
Source: Cypress: cy.session()
This snippet is the documentation’s pattern for an unspecified backend; it was not run because Bookshelf uses cookies. Practical points:
-
Pass
log: falsetocy.request()and.type()calls that carry credentials, and read the credentials withcy.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 |
|
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 |
|
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 ( |
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) |
|
Requires |
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 |
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 |
The documentation does not recommend this in CI: bot detection can block the account and the terms of service may forbid
it. Requires |
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)
Source: Cypress: cy.origin()
| Part | Meaning |
|---|---|
|
The secondary origin: at least a hostname, optionally scheme and port. The hostname must match exactly, including
subdomains. Defaults to |
|
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. |
|
Stringified, sent to a second Cypress instance that Cypress injects into the secondary origin, and evaluated there with
|
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 |
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');
});
});
Source: Cypress: cy.origin()
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`.
Source: Cypress: Cross origin testing
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`.
Source: Cypress: Cross origin testing
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
hrefof 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: trueapplies 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 theSec-Fetch-Destheader. Use it when a provider’s page breaks only under Cypress; the Entra ID and social login guides require it.removeSRIAttributesremovesintegrityattributes 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: |
Synchronous function: |
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 |
|
|
Works inside |
Yes, read the secret inside the callback |
Pass the value through |
Setting values
| Source | Notes |
|---|---|
|
|
|
In the project root; overrides conflicting config-file values. Add it to |
|
|
|
Comma-separated. Pass objects as JSON: |
|
|
|
Public values for |
|
CLI values override the config file. |
Test configuration |
|
cy.env()
cy.env(keys)
cy.env(keys, options)
Source: Cypress: cy.env()
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;
},
});
},
},
});
Source: Cypress: Cypress.expose()
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');
});
});
Source: Cypress: cy.env()
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 |
|---|---|---|
|
|
Asynchronous: the code that uses the value moves inside |
|
|
Synchronous. Move the key from |
A plain helper such as |
A custom command that reads |
A helper cannot return a secret any more because |
|
|
The |
|
Delete the line |
The option was removed; Cypress warns while it remains. |
A plugin that reads |
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 |
|---|---|---|
|
string |
URL to match, with minimatch wildcards ( |
|
string array, optional |
Paths to CA files to validate certificates against, relative to the project root. |
|
object array |
Each item is a PEM pair ( |
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]
Source: Cypress: Client certificates
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);
});
});
Source: Cypress: Client certificates
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.
Source: Cypress: Client certificates
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 |
|---|---|---|
|
string |
PEM file; a relative path resolves against the project root. |
|
string |
Inline PEM text. |
|
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-errorsand--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 bothfilePathandspki("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"). Anspkientry 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=3600was requested once with and withouttrustedCertificates, 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 optionallyHTTPS_PROXY. A falsyHTTP_PROXY(false,0, empty) turns the proxy off. If both an uppercase and a lowercase variable exist, the lowercase one wins. -
NO_PROXYis 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>inNO_PROXY. -
Resolution order differs by phase:
| Phase | Proxy resolution (first match wins) | Custom CA sources |
|---|---|---|
Installation |
|
npm |
Running tests |
|
npm |
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);
});
Source: Cypress: Proxy configuration
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", ...]
Source: Cypress: Proxy configuration
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 acy.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 uselog: 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
trustedCertificateshelps Firefox, WebKit or Electron. It does not apply there. -
Loopback behind a proxy. It is bypassed unless
<-loopback>is inNO_PROXY.
What changed recently
-
12.0.0:
cy.session()andcy.origin()are always available (theexperimentalSessionAndOriginflag was removed). -
14.0.0:
document.domainis no longer injected, socy.origin()is required between any two origins in a test;injectDocumentDomainrestores the old behavior and is deprecated. -
15.10.0:
Cypress.env()deprecated;cy.env()andCypress.expose()introduced. -
15.16.0: ECDSA keys supported in
clientCertificates. -
16.0.0 (1 September 2026):
Cypress.env()removed, theenvkey removed from test and suite configuration overrides andallowCypressEnvremoved; Chrome, Chromium and Edge use the native browser network (which is why client certificates cannot be limited to a path there;forceHttp1: trueis the deprecated escape hatch); the Electron browser is deprecated. -
16.1.0 (15 September 2026): the
trustedCertificatesoption. -
16.2.0 (13 October 2026): no change to these features; the version used for the examples on this page.