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.
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:
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.
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 a page on that origin.Date.now(). setup re-runs. Then you know the net exists.