# My restored Cypress session was lying to me

> Source: <https://dev.to/marcelo_sqe/my-restored-cypress-session-was-lying-to-me-4648>
> Published: 2026-09-07 15:36:23+00:00

**Author's Note / Disclosure:** 100% human-authored content based on real production engineering work. No AI was involved in writing the article, technical analysis, or code.

`cy.session()` is the single biggest speed win available to an authenticated Cypress suite. You log in once, Cypress snapshots cookies, `localStorage` and `sessionStorage`, and every later spec restores that snapshot instead of walking through an identity provider.

The safety net is `validate()`. Cypress runs it after restoring a cached session; if it throws, fails an assertion, or yields `false`, Cypress throws the snapshot away and runs `setup` again. That is the whole contract: **a bad session gets detected and replaced.**

Mine could not fail. For weeks. And it cost me days of chasing "flaky" specs that were nothing of the kind.

``` js
Cypress.Commands.add('login', (user: User) => {
  cy.session(
    user.username,
    () => {
      cy.visit('/')
      cy.origin(idpOrigin, { args: user }, ({ username, password }) => {
        cy.get('#username').type(username)
        cy.get('#password').type(password, { log: false })
        cy.get('button[type="submit"]').click()
      })
      cy.get('#app-shell').should('be.visible')
    },
    {
      cacheAcrossSpecs: true,
      validate() {
        cy.request('/connect/userinfo').its('status').should('eq', 200)
      },
    },
  )
})
```

Reasonable, right? `/connect/userinfo` is the OIDC user info endpoint. If the session is dead it should 401, `validate()` fails, and we log in again.

Two independent bugs stack up here, and either one alone is enough to make the check worthless.

**The URL is relative.** `cy.request('/connect/userinfo')` resolves against `baseUrl`, which is the application, not the identity provider. So the request never touches the IdP.

**The application is a single-page app.** Its host serves `index.html` for any path it does not recognise, because that is what history-API routing requires. A request for `/connect/userinfo` gets back the SPA shell with `200 OK` and `content-type: text/html`.

`validate()` asserted on a status code. It got a `200`. Every single time. Including for a session whose refresh token expired three hours ago.

You can confirm this in one command, without Cypress:

```
curl -is https://app.example.com/connect/userinfo | head -5
```

If you see `200` and `text/html`, your validate is decorative.

This is the part that burns time. The session restores, `validate()` passes, and then the *first real command* of your test triggers the app's own auth check, which redirects to the identity provider. Your spec fails like this:

```
AssertionError: Timed out retrying after 10000ms:
Expected to find element: `#dashboard-grid`, but never found it.
```

Nothing points at authentication. The URL in the screenshot is the IdP login page, but you are looking at a grid selector, so you go and inspect the grid. Across a large suite the pattern shows up as an unrelated scatter of failures in whichever specs happened to run after the token expired — the exact fingerprint people label "flaky" and hand a retry.

The tell: **the failure moves around between runs, but always lands on the first assertion after a restored session.**

I tried the two obvious ones first.

**Point at the absolute IdP URL.** `cy.request('https://idp.example.com/connect/userinfo')` now reaches the right server, but `cy.request` sends the browser's cookie jar and nothing else. If your SPA authenticates with a bearer token — and if you are using `oidc-client-ts`, `MSAL`, `angular-auth-oidc-client` or friends, you are — then the token lives in web storage and is attached by an HTTP interceptor inside the app. `cy.request` has no interceptor. You get a `401` for a session that is perfectly alive, `setup` re-runs on every spec, and you have deleted the entire benefit of `cy.session`.

**Call a real application API instead.** Same problem, same reason. You are testing whether cookies alone can authenticate, which is not how the app works.

Both failures come from the same mistake as the original bug, just inverted: the check is exercising a credential path the application does not use.

The application decides it is logged in by reading a token out of storage. So that is what `validate()` should assert on.

```
interface StoredUser {
  access_token: string
  expires_at?: number
}

function readStoredUser(win: Window): StoredUser | null {
  const key = Object.keys(win.sessionStorage).find((k) => k.startsWith('oidc.user:'))
  if (!key) return null
  const raw = win.sessionStorage.getItem(key)
  return raw ? (JSON.parse(raw) as StoredUser) : null
}

function expiryMs(user: StoredUser): number | null {
  if (user.expires_at) return user.expires_at * 1000
  const [, payload] = user.access_token.split('.')
  if (!payload) return null
  const json = JSON.parse(
    atob(payload.replace(/-/g, '+').replace(/_/g, '/')),
  ) as { exp?: number }
  return json.exp ? json.exp * 1000 : null
}
```

And the validate itself:

``` js
validate() {
  cy.visit('/')
  cy.window({ log: false }).then((win) => {
    const user = readStoredUser(win)
    expect(user, 'stored auth record').to.not.be.null

    const expires = expiryMs(user as StoredUser)
    expect(expires, 'token expiry claim').to.be.a('number')
    expect(expires as number, 'token still valid with margin')
      .to.be.greaterThan(Date.now() + 30_000)
  })
}
```

Three details in there matter more than the rest.

**The `cy.visit('/')` is not optional.** `validate()` runs before your test's own `cy.visit`. Until a page on the app origin is loaded, the application under test is a blank frame, and `cy.window()` hands you storage that is empty regardless of session health. Skip the visit and you have built the opposite bug: a validate that can never *pass*, so `setup` runs every spec. Read the storage from a page on the origin that owns it.

**The 30-second margin.** A token with four seconds left passes a naive `> Date.now()` check and then expires in the middle of your test. Require enough runway to finish the spec. Tune the number to your slowest test, not your fastest.

**The storage key prefix is library-specific.** `oidc.user:<authority>:<client_id>` is `oidc-client-ts`. MSAL shards the account, token and metadata across separate keys. Open DevTools, look at what your app actually wrote, and key off that. If your app uses an httpOnly cookie session instead, the same principle points somewhere else entirely: assert against an endpoint that authenticates *by cookie*, absolute-URL'd, and confirm by hand that it 401s when logged out.

This is the step I want you to take away, because it generalises past auth. A guard you have never seen fire is not a guard.

``` js
it('re-runs setup when the stored token is gone', () => {
  cy.login(user)
  cy.visit('/')
  cy.window().then((win) => win.sessionStorage.clear())
  cy.login(user)
  cy.get('#app-shell').should('be.visible')
})
```

Better still, break it on purpose once by hand: clear the token, re-run a spec, and watch the Cypress command log. You are looking for the `setup` block re-executing. If it does not, your `validate()` is a comment with extra steps.

`validate()` must assert on the credential the application actually sends, in the context the application actually reads it.`200` forever.`cy.request` carries cookies, never your in-app bearer token. Do not use it to check token-based sessions.`cy.visit`, so read web storage only after loading a page on that origin.`Date.now()`.` setup` re-runs. Then you know the net exists.
