My restored Cypress session was lying to me
A cy.session validate() that hits a relative URL on a single-page app can never fail. Here is why dead sessions restore green, and how to validate the credential your app actually uses.
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; AI was used solely to assist with multilingual translations (Portuguese and Spanish).
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.
The code that looked fine
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.
Why it always passes
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.
What the failure looks like from the outside
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:
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.
Fixes that do not work
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 fix: validate what the app reads
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:
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.
Prove your validate can fail
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.
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.
Rules
validate()must assert on the credential the application actually sends, in the context the application actually reads it.- A relative URL in
validate()on a single-page app resolves to the SPA shell. It will return200forever. cy.requestcarries cookies, never your in-app bearer token. Do not use it to check token-based sessions.validate()runs before yourcy.visit, so read web storage only after loading a page on that origin.- Validate expiry with a margin, not against
Date.now(). - Deliberately break the session once and confirm
setupre-runs. Then you know the net exists.
