{"slug": "my-restored-cypress-session-was-lying-to-me", "title": "My restored Cypress session was lying to me", "summary": "A developer discovered that their Cypress test suite's session validation was ineffective due to two stacked bugs: a relative URL that never reached the identity provider and a single-page app returning a 200 OK for any path. This caused tests to fail sporadically, misleadingly labeled as flaky, and cost days of debugging. The developer recommends verifying that validation requests hit the correct server and receive the expected content type.", "body_md": "**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.\n\n`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.\n\nThe 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.**\n\nMine could not fail. For weeks. And it cost me days of chasing \"flaky\" specs that were nothing of the kind.\n\n``` js\nCypress.Commands.add('login', (user: User) => {\n  cy.session(\n    user.username,\n    () => {\n      cy.visit('/')\n      cy.origin(idpOrigin, { args: user }, ({ username, password }) => {\n        cy.get('#username').type(username)\n        cy.get('#password').type(password, { log: false })\n        cy.get('button[type=\"submit\"]').click()\n      })\n      cy.get('#app-shell').should('be.visible')\n    },\n    {\n      cacheAcrossSpecs: true,\n      validate() {\n        cy.request('/connect/userinfo').its('status').should('eq', 200)\n      },\n    },\n  )\n})\n```\n\nReasonable, right? `/connect/userinfo` is the OIDC user info endpoint. If the session is dead it should 401, `validate()` fails, and we log in again.\n\nTwo independent bugs stack up here, and either one alone is enough to make the check worthless.\n\n**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.\n\n**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`.\n\n`validate()` asserted on a status code. It got a `200`. Every single time. Including for a session whose refresh token expired three hours ago.\n\nYou can confirm this in one command, without Cypress:\n\n```\ncurl -is https://app.example.com/connect/userinfo | head -5\n```\n\nIf you see `200` and `text/html`, your validate is decorative.\n\nThis 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:\n\n```\nAssertionError: Timed out retrying after 10000ms:\nExpected to find element: `#dashboard-grid`, but never found it.\n```\n\nNothing 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.\n\nThe tell: **the failure moves around between runs, but always lands on the first assertion after a restored session.**\n\nI tried the two obvious ones first.\n\n**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`.\n\n**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.\n\nBoth failures come from the same mistake as the original bug, just inverted: the check is exercising a credential path the application does not use.\n\nThe application decides it is logged in by reading a token out of storage. So that is what `validate()` should assert on.\n\n```\ninterface StoredUser {\n  access_token: string\n  expires_at?: number\n}\n\nfunction readStoredUser(win: Window): StoredUser | null {\n  const key = Object.keys(win.sessionStorage).find((k) => k.startsWith('oidc.user:'))\n  if (!key) return null\n  const raw = win.sessionStorage.getItem(key)\n  return raw ? (JSON.parse(raw) as StoredUser) : null\n}\n\nfunction expiryMs(user: StoredUser): number | null {\n  if (user.expires_at) return user.expires_at * 1000\n  const [, payload] = user.access_token.split('.')\n  if (!payload) return null\n  const json = JSON.parse(\n    atob(payload.replace(/-/g, '+').replace(/_/g, '/')),\n  ) as { exp?: number }\n  return json.exp ? json.exp * 1000 : null\n}\n```\n\nAnd the validate itself:\n\n``` js\nvalidate() {\n  cy.visit('/')\n  cy.window({ log: false }).then((win) => {\n    const user = readStoredUser(win)\n    expect(user, 'stored auth record').to.not.be.null\n\n    const expires = expiryMs(user as StoredUser)\n    expect(expires, 'token expiry claim').to.be.a('number')\n    expect(expires as number, 'token still valid with margin')\n      .to.be.greaterThan(Date.now() + 30_000)\n  })\n}\n```\n\nThree details in there matter more than the rest.\n\n**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.\n\n**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.\n\n**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.\n\nThis 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.\n\n``` js\nit('re-runs setup when the stored token is gone', () => {\n  cy.login(user)\n  cy.visit('/')\n  cy.window().then((win) => win.sessionStorage.clear())\n  cy.login(user)\n  cy.get('#app-shell').should('be.visible')\n})\n```\n\nBetter 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.\n\n`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.", "url": "https://wpnews.pro/news/my-restored-cypress-session-was-lying-to-me", "canonical_source": "https://dev.to/marcelo_sqe/my-restored-cypress-session-was-lying-to-me-4648", "published_at": "2026-09-07 15:36:23+00:00", "updated_at": "2026-09-07 15:57:05.051430+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Cypress"], "alternates": {"html": "https://wpnews.pro/news/my-restored-cypress-session-was-lying-to-me", "markdown": "https://wpnews.pro/news/my-restored-cypress-session-was-lying-to-me.md", "text": "https://wpnews.pro/news/my-restored-cypress-session-was-lying-to-me.txt", "jsonld": "https://wpnews.pro/news/my-restored-cypress-session-was-lying-to-me.jsonld"}}