PRACTICAL GUIDE / Cypress cy.session authentication

Why cy.session restores a login that no longer works

Learn how Cypress caches browser sessions, why restored logins return 401 or a blank page, and how to validate, key, and debug them safely in CI.

By The Testing AcademyUpdated August 7, 202619 min read
All field guides
In this guide6 sections
  1. Know exactly what Cypress saves
  2. Build a session helper with a real identity check
  3. Diagnose three failures before changing timeouts
  4. Separate session bugs from application bugs
  5. Separate a stale restore from broken fresh issuance
  6. Roll the helper into an existing suite
  7. Do not cache when the session is the behavior under test

What you will learn

  • Know exactly what Cypress saves
  • Build a session helper with a real identity check
  • Diagnose three failures before changing timeouts
  • Separate session bugs from application bugs

The first Cypress spec logs in, and the next one lands on a blank page with a 401 from the identity endpoint. The cached session exists. The authenticated user does not.

Know exactly what Cypress saves

The session command accepts an id, a setup function, and optional validation and cross-spec caching settings. Cypress caches cookies, localStorage, and sessionStorage after setup. A later call with the same id can restore that browser state instead of running setup again.

That description has four boundaries worth remembering. First, the cache contains browser session data, not the application database. If one test deletes the shared account or changes its role, restoring cookies cannot undo that server-side change. Second, the documented storage set is cookies plus the two Web Storage areas. If the application needs IndexedDB, an in-memory token, or a service worker's state to become authenticated, do not assume the session command captured it. Third, the cache is scoped to a Cypress run and machine. Fourth, a restored value can be syntactically present but no longer accepted by the server.

Cypress clears cookies, localStorage, and sessionStorage before setup. That session-data clearing happens regardless of the testIsolation setting. When testIsolation is true, which is the end-to-end default, Cypress also clears the page while establishing the session. The command can finish on a blank document. The test must visit its intended route afterwards.

This is why a login helper should not promise that a dashboard is already open. Its contract is narrower: establish and validate a browser session for a named identity. Navigation belongs in the test or its beforeEach hook.

Validation turns cached bytes into an authentication contract. Cypress runs validate after a session is created and after one is restored. If validation fails immediately after setup, the test fails. If it fails after restoration, Cypress runs setup again and validates the newly created session. A validation function that checks only for a cookie name misses server expiry, revocation, wrong tenant, and role changes.

The id is the other half of the contract. Cypress deterministically serializes string, array, or object ids. Every input that can produce materially different session data needs representation in that id. The environment, user, tenant, role, login method, and an authentication schema revision are useful. Passwords and tokens are not. Cypress shows the id in its reporter, so an id is not a secret store.

With cacheAcrossSpecs set to true, a session can be restored by other specs in the same Cypress run on the same machine. The current Cypress documentation is explicit that this cache lives in memory. It is not written to disk or shared between parallel machines. Seeing one login per CI shard is expected, not evidence that caching failed.

Build a session helper with a real identity check

Put the session call in one shared custom command or helper. Cypress requires specs reusing a global session to use the same id, setup, validate, and cacheAcrossSpecs value. Copying similar implementations into multiple specs invites drift and, for a global session with the same id, can produce an error instead of reuse.

The following TypeScript command logs in through an API that sets a session cookie. Cypress documents that cy.request() receives Set-Cookie headers and updates the browser cookie jar. The cache key includes nonsecret factors that change the session meaning. Validation asks the protected identity endpoint who is logged in.

TypeScript
type TestIdentity = {
  userId: string;
  username: string;
  role: 'viewer' | 'editor' | 'admin';
  tenant: string;
};

declare global {
  namespace Cypress {
    interface Chainable {
      loginAs(identity: TestIdentity): Chainable<null>;
    }
  }
}

Cypress.Commands.add('loginAs', (identity: TestIdentity) => {
  const environment = Cypress.config('baseUrl') ?? 'no-base-url';
  const authRevision = Cypress.env('AUTH_SCHEMA_VERSION') ?? 'v1';

  return cy.session(
    [environment, identity.userId, identity.tenant, identity.role, authRevision],
    () => {
      const password = Cypress.env('E2E_PASSWORD');

      if (typeof password !== 'string' || password.length === 0) {
        throw new Error('E2E_PASSWORD is required');
      }

      cy.request({
        method: 'POST',
        url: '/api/login',
        body: {
          username: identity.username,
          password,
          tenant: identity.tenant,
        },
        log: false,
      }).its('status').should('eq', 200);
    },
    {
      validate() {
        cy.request({
          url: '/api/me',
          failOnStatusCode: false,
          log: false,
        }).then((response) => {
          expect(response.status).to.equal(200);
          expect(response.body.userId).to.equal(identity.userId);
          expect(response.body.tenant).to.equal(identity.tenant);
          expect(response.body.roles).to.include(identity.role);
        });
      },
      cacheAcrossSpecs: true,
    },
  );
});

export {};

This code is runnable once the application-specific routes and response fields match your service. The password is deliberately absent from the session id and command log. The auth revision gives the team a safe manual invalidation lever when cookie names, token claims, or login behavior change. Incrementing it costs a fresh login per cache scope.

The validation response must identify the current principal. A generic health endpoint returning 200 proves only that the server is alive. An endpoint that returns 200 to anonymous users is equally weak. Assert the stable fields that affect authorization. Do not assert volatile profile fields that have nothing to do with the session, or harmless profile edits will force needless logins.

Use the helper before navigation:

TypeScript
const admin = {
  userId: 'e2e-admin-17',
  username: 'e2e.admin@example.test',
  role: 'admin' as const,
  tenant: 'training',
};

describe('admin audit log', () => {
  beforeEach(() => {
    cy.loginAs(admin);
    cy.visit('/admin/audit');
  });

  it('shows the authenticated tenant and role', () => {
    cy.get('[data-testid="current-tenant"]').should('have.text', 'training');
    cy.get('[data-testid="current-role"]').should('have.text', 'admin');
    cy.get('[data-testid="audit-table"]').should('be.visible');
  });
});

The visit after login is not redundant. It makes the test independent of whether Cypress created or restored the session and independent of which page setup happened to touch. It also keeps navigation evidence in the spec that owns the page assertion.

An API login is a speed optimization with coverage cost. Cypress makes cy.request() from its Node process, not as a browser XHR or fetch. The command bypasses browser CORS behavior and does not appear as a request in the browser's Network panel. Keep a dedicated UI login spec for the form, frontend validation, redirect, cookie behavior in a browser navigation, and accessible error presentation.

Diagnose three failures before changing timeouts

The blank-page failure is the quickest to identify. Commands immediately after cy.session() cannot find page elements, and the current URL is about:blank when testIsolation is enabled. The session may be valid. Add cy.visit() after the helper. Increasing the element timeout waits longer on a document that will never contain the application.

A related near-miss occurs when testIsolation is false for a describe block. The page is not cleared by the session command under that setting, so the test can accidentally pass without an explicit visit. Run the same test alone or re-enable isolation and it fails. That is a dependency on leftover page state, not a session performance win.

The stale-session failure produces a different sequence. The command log shows a saved session being restored, then a protected request returns 401 or the application redirects to sign-in. If validate is absent, Cypress has no server-backed reason to reject the cached state. If validate checks only localStorage presence, an expired token still passes the local check.

Add the identity request to validate. When a restored session fails that validation, Cypress should recreate it by running setup. In the command log, the session group distinguishes creation, restoration, and recreation after failed validation. Expand that group before looking at unrelated page commands. It tells you whether the login setup ran on the failing attempt.

Do not use cy.intercept() to observe an API login performed by cy.request(). Cypress documents that cy.request() bypasses routes defined with cy.intercept(), because the request is made outside the browser. Inspect the cy.request command result or service logs using a nonsecret correlation ID. Waiting for a browser intercept that can never see the request creates a misleading timeout.

The session-key collision is subtler. A viewer test and an admin test both call login('alex'), but setup chooses different tenants or roles. Cypress sees one id and restores the first cached state. The page then returns 403, or identity validation says the wrong principal is active. The defect is deterministic but may depend on spec order.

The fix is to key on every input used by setup that changes session data. Do not add random data to every id. A random id prevents collisions by disabling reuse, hiding the design problem and losing the speed benefit. Use a structured id whose displayed form helps diagnosis without revealing credentials.

Environment collisions produce the same symptom. A local run, preview deployment, and staging environment can share a username while issuing cookies for different domains or audiences. Include the base URL or an explicit environment identifier. Also include tenant and login method if the same identity can authenticate in different modes.

The near-miss for a role collision is server-side account mutation. The cache key is correct, but another test changed the user's role after login. The restored session identifies the expected user while /api/me reports a different role. Recreating the session may not fix it because the account itself is now different. Reset backend data or allocate isolated users. Browser session isolation cannot substitute for test-data isolation.

A fourth common issue appears only in parallel CI. Engineers enable cacheAcrossSpecs and expect a single login for the entire pipeline. Each machine starts its own Cypress process, so each has an empty in-memory cache. The evidence is one setup per machine followed by restores on that same machine. Copying browser storage files between workers is not what cacheAcrossSpecs promises and can create a new security and expiry problem.

Separate session bugs from application bugs

Use the identity endpoint as the dividing line. If validate returns the expected user, tenant, and role, but the page still shows sign-in, investigate frontend bootstrapping, cookie scope on browser requests, a second token store, or authorization logic on that route. If validate returns 401, focus on expiry, revocation, login setup, environment, and cookie delivery.

When the Node-side identity check passes but the page redirects, observe the browser's own request. Install the intercept after the session helper and before the visit. Unlike the login request made with cy.request(), this intercept sees traffic initiated by the application in the browser.

TypeScript
it('sends the restored session on the browser identity request', () => {
  cy.loginAs({
    userId: 'e2e-viewer-31',
    username: 'e2e.viewer@example.test',
    role: 'viewer',
    tenant: 'training',
  });

  cy.intercept('GET', '**/api/me').as('browserIdentity');
  cy.visit('/account');

  cy.wait('@browserIdentity').then(({ response }) => {
    expect(response, 'browser identity response').to.exist;
    expect(response?.statusCode).to.equal(200);
    expect(response?.body.userId).to.equal('e2e-viewer-31');
  });
});

If this browser request is absent, the application may use a different bootstrap path or fail before identity loading. If it is present but unauthorized while validate passed, compare hosts and cookie metadata. A host-only cookie, Domain value, Path, Secure flag, or SameSite policy can make the Node-side request and browser navigation behave differently. Record attributes, not the cookie value.

A service worker adds another near-miss. The page can display a cached signed-in shell while the live identity request fails. A screenshot then looks authenticated even though the server session is gone. Assert the live principal and a protected operation, not only the presence of an avatar or account menu.

Remember that cy.request() and the browser do not take identical network paths. The Node-side identity request can succeed while a browser request fails because of CORS, SameSite behavior, frontend request configuration, a service worker, or a different host. That is why validation is necessary but not sufficient. The protected-page visit and visible identity assertion remain part of the test.

Check the first failed command, not the final screenshot alone. A screenshot of the sign-in page cannot tell whether Cypress never restored storage, the server rejected the session, or the application redirected because a role was missing. The command log and identity response locate the boundary.

Inspect storage names and cookie metadata without printing values. Record cookie name, domain, path, Secure, HttpOnly, SameSite, and expiry. For Web Storage, record keys and whether a value exists. Do not attach a full session dump to CI. Cypress's session detail view can contain cached cookies and storage values, so treat console output from that panel as sensitive.

Compare creation and restoration explicitly while debugging. Clear saved sessions, run the focused spec, and note the first creation. Run another test with the same helper in the same process and confirm restoration. Cypress exposes a documented method for clearing saved sessions during diagnosis:

TypeScript
describe('session contract', () => {
  const editor = {
    userId: 'e2e-editor-22',
    username: 'e2e.editor@example.test',
    role: 'editor' as const,
    tenant: 'training',
  };

  beforeEach(() => {
    Cypress.session.clearAllSavedSessions();
  });

  it('establishes the expected identity from an empty cache', () => {
    cy.loginAs(editor);
    cy.visit('/account');

    cy.request('/api/me').then((response) => {
      expect(response.status).to.equal(200);
      expect(response.body.userId).to.equal(editor.userId);
      expect(response.body.roles).to.include('editor');
    });
  });
});

Do not keep this beforeEach in the performance-oriented regression suite. It deliberately disables reuse. Use it in a focused diagnostic spec or while reproducing a cache-sensitive bug. The trade-off is a slower run and more load on the identity service, but the empty-cache evidence is valuable when narrowing a failure.

Retries require similar discipline. A retry may create a fresh session after the first attempt exposed an invalid cached one. The final status becomes green, but the restoration defect remains. Preserve the first attempt's command log and session status. During diagnosis, run the focused spec without retries so creation, restoration, and failure are not mixed across attempts.

Separate a stale restore from broken fresh issuance

Two defects can begin with the same sequence: the session group reports a restore, validation has a 401 status, and Cypress starts setup. One is an expired or revoked cached credential. The other is a fresh login that no longer produces a credential the identity endpoint can use, perhaps because the cookie is omitted, scoped incorrectly, or rejected immediately. The final screenshot and first 401 do not separate them.

Read through the second validation. A stale restore produces restore, validation 401, setup login 200, then validation 200 with the expected principal. Broken fresh issuance can show setup login 200 followed immediately by another validation 401. With an empty cache, stale-cache behavior disappears on first creation, while issuance failure reproduces.

On each request result, read status first, then read identity fields only for an authenticated response. A healthy result combines status 200 with the expected user, tenant, and role. A broken result is 401, or status 200 paired with a different stable identity. Login status 200 alone is misleading because it does not prove the protected endpoint accepts the resulting session.

Compare the login response's Set-Cookie header with sanitized cookie metadata after setup. A healthy cookie is stored and its domain and path permit the validation URL. No stored counterpart, or scope that excludes the URL, points to issuance rather than restoration. If the cookie is attached but rejected, give the identity service the ordered statuses and a nonsecret correlation identifier, never the cookie value.

Expiry tests need controlled server behavior. Waiting for a real token lifetime makes the suite slow and fragile. Prefer a test-only way to issue a short-lived or already-invalid session in a protected non-production environment, then assert validation causes recreation. Do not modify production token lifetime for the test and do not sleep for guessed durations.

Roll the helper into an existing suite

Inventory every login path first. Teams often have UI logins in beforeEach hooks, API shortcuts in custom commands, direct cookie setters, copied localStorage fixtures, and tests that assume an earlier spec authenticated the browser. Choose one supported helper per authentication mode and migrate a small role at a time.

Keep testIsolation enabled during the migration. Disabling it can make the suite faster, but Cypress warns that browser state can leak between tests. A passing ordered run may fail under .only or a different spec order. Session caching is designed to recover authentication speed while preserving test independence.

Set the configuration explicitly so the suite's assumption is visible:

TypeScript
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    testIsolation: true,
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
  },
});

Replace the base URL with the environment's real value through the configuration mechanism your pipeline already uses. Do not commit staging passwords to cypress.config.ts or cypress.env.json. Keep secrets in the CI secret store and prevent request bodies from entering retained logs.

Before changing call sites, agree which protected identity response is the contract, including anonymous behavior and stable fields. Land the shared helper and a narrow contract spec while old helpers remain. Exercise an empty-cache creation and a second call in the same process, proving both setup and restoration before broad migration.

The earliest failures often reveal that the old login helper mixed authentication with ordinary fixture setup. Aliases, generated record identifiers, and other command outputs are not cached session data, so they disappear when restoration skips setup. Move that work to the normal test setup outside cy.session(). If a browser login triggers product behavior the test genuinely verifies, keep that caller on the browser path. Remove old helpers only when every caller has an intentional replacement.

A working tranche shows setup for the first identity use, restoration later, and passing validation after both paths. Run it alone and in its normal CI grouping. The optimization is fewer login submissions, not fewer validations. A helper called before every test still adds one protected identity request per test, costing identity-service traffic and a network round trip on the critical path. A slow identity endpoint can erase much of the saved time even when caching works.

Migrate one identity class first, such as read-only users. Add the strong /api/me validation, an explicit visit, and a visible role assertion. Run the spec alone, in a group, and with reversed order where practical. Then move editor and admin roles, using distinct keys and isolated accounts.

Keep the real login form test outside the shortcut. It should submit through the browser and check both success and failure behavior. Do not call cy.session() in that one test merely to save time, because creation may be skipped after the cache exists. The purpose of that spec is to exercise login.

Expect each parallel CI worker to create its own session. Size identity-service capacity for the number of workers and roles, not for one global login. If setup volume becomes a problem, reduce unnecessary identities or group specs intelligently. Do not export live session cookies as a generic CI artifact.

The following GitHub Actions shape makes the machine boundary explicit. Each matrix job runs one Cypress process and owns its own in-memory session cache.

YAML
name: cypress-auth-regression

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  cypress:
    strategy:
      fail-fast: false
      matrix:
        spec:
          - cypress/e2e/account.cy.ts
          - cypress/e2e/admin-audit.cy.ts
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Run one spec on this worker
        env:
          CYPRESS_E2E_PASSWORD: ${{ secrets.CYPRESS_E2E_PASSWORD }}
          CYPRESS_AUTH_SCHEMA_VERSION: v3
        run: npx cypress run --spec "${{ matrix.spec }}"

Do not read this matrix as a recommendation to split every spec onto its own machine. That would maximize login setup and runner cost. It simply shows why cacheAcrossSpecs cannot cross workers. Group specs according to runtime, isolation needs, and identity-service limits.

Do not cache when the session is the behavior under test

Avoid the helper in login, logout, password-reset, account-lockout, multi-factor, consent, and session-expiry specifications. Those tests need to observe the authentication transition. A cached setup can skip the behavior the spec is supposed to prove.

Do not cache a mutable shared administrator account across tests that change permissions, tenant membership, password, or revocation state. Even perfect browser restoration cannot make server state independent. Allocate a fresh account, reset it through an approved test API, or keep the scenario serial and explicit.

Do not use cacheAcrossSpecs when the environment cannot guarantee stable account state for the duration of one run. The extra login cost may be cheaper than diagnosing cross-spec contamination. Start with per-spec caching, then enable global reuse after the identity and data contracts are stable.

Treat logout as its own transition too. A test that restores a cached login, clicks Sign out, and then calls the same helper again can legitimately receive a recreated session. That does not prove the old cookie survived logout. Verify the protected endpoint rejects the logged-out browser before asking the helper to establish a new identity.

Multi-factor and step-up authentication need the same care. Cache a post-challenge session only for tests whose setup contract permits that shortcut. Keep separate coverage for challenge enrollment, code entry, recovery, remembered-device behavior, and step-up expiry. One cached “admin” state should not silently bypass every security transition in the suite.

Route ownership according to the first boundary that disagrees. The test-infrastructure owner owns helper semantics, session ids, validation assertions, and diagnostic redaction. The identity-service owner owns a fresh login that cannot survive immediate validation. The frontend owner takes a case where Node-side validation succeeds but the browser bootstrap fails. The CI owner handles worker-local setup volume, secret injection, and retained artifacts.

A useful handoff contains the nonsecret id components, expected identity and environment, spec and worker context, whether Cypress created or restored, and request statuses in execution order. Include sanitized cookie name, domain, path, expiry, Secure, HttpOnly, and SameSite attributes, plus the empty-cache reproduction result and a nonsecret correlation identifier when available. Exclude passwords, tokens, cookie values, and full session-detail dumps. This package lets the receiving team test its boundary without reconstructing the Cypress run from a screenshot.

Identity validation does not catch resource-level authorization defects. The endpoint can correctly report the expected user, tenant, and role while another API exposes a record that user should not read or permits an operation that role should not perform. Keep assertions against the protected business resource and its denial cases. A green session contract proves who the caller is, not that every downstream service enforces what that caller may do.

Do not force cy.session() onto an application whose authentication depends on browser state outside cookies, localStorage, and sessionStorage unless the app can reconstruct that state after a visit. A service worker or IndexedDB dependency needs its own supported setup and evidence. Validation should fail clearly rather than accepting a partially restored login.

Do not weaken validation to gain speed. A cookie-exists check is fast because it asks almost nothing. A small protected identity request costs a network round trip but prevents a stale or wrongly keyed session from contaminating many tests. That is a deliberate latency trade-off.

Finally, do not include credentials in ids, logs, screenshots, or copied session diagnostics. The helper is test infrastructure with access to authentication material. Review it like production security-sensitive code. Fast login setup is useful only when the suite still proves which user is active and keeps that user's session out of retained evidence.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 26, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official docs.cypress.io reference

    docs.cypress.io

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official docs.cypress.io reference

    docs.cypress.io

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official docs.cypress.io reference

    docs.cypress.io

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official docs.cypress.io reference

    docs.cypress.io

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why is the page blank after cy.session?

With testIsolation enabled, Cypress clears the page while establishing or restoring a session. Call cy.visit() after the session helper returns so the test loads the page it intends to exercise.

What should a cy.session validate function check?

Ask a protected identity endpoint for the current user and assert the expected user, tenant, and role. Checking only that a cookie exists can accept an expired, revoked, or wrongly scoped session.

Does cacheAcrossSpecs share login state between CI machines?

No. Cypress keeps the global session for one Cypress run on one machine, and it does not write that cache to disk. Parallel machines and new runs create their own sessions.

Can cy.session replace every login UI test?

Keep at least one focused test for the real form, redirects, browser requests, error messages, and accessibility. An API setup using cy.request() bypasses the browser's CORS enforcement and does not prove the user-facing login path works.

Should a password or token be part of the session id?

Avoid sensitive data in the id because Cypress displays it in the reporter. Use nonsecret inputs that define the session, such as environment, user ID, tenant, role, and an authentication schema revision.