cy.session is Cypress's built-in answer to "I don't want to log in a hundred times." It runs the setup once, snapshots the resulting cookies and storage, and on every subsequent call with the same key restores that snapshot in milliseconds. This lesson covers the syntax, the validate hook that keeps stale sessions from leaking through, the cross-spec cache, and the multi-role patterns you'll lean on for any suite that has more than a single user identity.
What cy.session does
The mental model is a key-value cache for browser identity:
- Key — the first argument. Any serialisable value: a string, a tuple, an object. Different keys = different cached sessions.
- Setup callback — the second argument. Runs the first time a key is seen. Cypress watches what cookies/localStorage/sessionStorage the callback produces and snapshots them.
- Restore on subsequent calls — Cypress writes the snapshot back into the browser. The page is cleared first; the storage is then planted; the test continues.
Cypress.Commands.add("loginWithSession", (email: string, password: string) => {
cy.session([email, password], () => {
cy.visit("/login");
cy.get("[data-testid='email']").type(email);
cy.get("[data-testid='password']").type(password);
cy.get("[data-testid='submit']").click();
cy.url().should("include", "/dashboard");
});
});beforeEach(() => {
cy.loginWithSession("alice@test.com", "Sup3rS3cret!");
cy.visit("/dashboard"); // ← always after the session call
});First test in the suite: full login (4 seconds). Every test after: restore (40 ms). A 30-test suite saves about 110 seconds.
The cache key
Use a value that uniquely identifies the session you're caching. The classic pattern is a tuple:
cy.session([email, password], setupCallback);If the suite uses three users — admin, standard, viewer — each gets its own cache:
cy.session(["admin@test.com", "Pass1!"], setupAdmin);
cy.session(["alice@test.com", "Pass1!"], setupAlice);
cy.session(["viewer@test.com", "Pass1!"], setupViewer);Cypress keeps three independent snapshots. Switching between roles in the same spec is a session-key flip, not a re-login.
Don't use static strings (cy.session("user", ...)). Two different users with the same key collide and the second test restores the first's cookies — silent bug, hard to spot.
The validate hook
Caching is great until it isn't. A token expires, a backend revokes a session, the cookie's max-age runs out — the cached snapshot is still in Cypress's memory but the server doesn't recognise it. The next test restores stale credentials and fails for confusing reasons.
cy.session accepts a validate callback that runs on every restore and decides whether the cache is still good:
cy.session(
[email, password],
() => {
// setup
cy.apiLogin(email, password);
},
{
validate() {
cy.request("/api/users/me").its("status").should("eq", 200);
},
},
);After the cookies are restored, validate runs. If it passes, the test continues with the restored session. If it fails (the request returns 401 because the token expired), Cypress automatically re-runs the setup and refreshes the cache.
This is the single feature that makes cy.session safe for long-running suites and stateful staging environments. Without validate, an expired token means a flaky run; with it, expiration is invisible.
Pick a cheap validation — a HEAD request to a protected endpoint, a GET /api/users/me, anything that's a one-shot HTTP call. Don't put a five-step UI navigation in validate or you've eaten the speed gain.
Caching across spec files
By default, cy.session caches within a single spec file. Run a second spec — the cache is empty again. For larger projects, opt into cross-spec caching:
cy.session(
[email, password],
setupCallback,
{
cacheAcrossSpecs: true,
validate() {
cy.request("/api/users/me").its("status").should("eq", 200);
},
},
);Now the snapshot is shared across every spec in the run. The first spec to call cy.session(["alice", "pw"], ...) does the setup; every spec after — same browser session, same cache — restores. On a 200-spec suite this turns the per-spec login overhead into per-suite overhead.
cacheAcrossSpecs: true is almost always paired with a validate hook, since the cache now lives across many spec boundaries and is more likely to go stale.
Multi-role tests in one suite
A typed pattern that makes role-switching trivial:
type Role = "admin" | "standard" | "viewer";
const credentials: Record<Role, { email: string; password: string }> = {
admin: { email: "admin@test.com", password: "AdminPass1!" },
standard: { email: "alice@test.com", password: "AlicePass1!" },
viewer: { email: "viewer@test.com", password: "ViewerPass1!" },
};
declare global {
namespace Cypress {
interface Chainable {
loginAs(role: Role): Chainable<void>;
}
}
}
Cypress.Commands.add("loginAs", (role: Role) => {
const c = credentials[role];
cy.session(
[role, c.email],
() => cy.apiLogin(c.email, c.password),
{
cacheAcrossSpecs: true,
validate: () => cy.request("/api/users/me").its("status").should("eq", 200),
},
);
});
export {};describe("Admin panel", () => {
beforeEach(() => {
cy.loginAs("admin");
cy.visit("/admin");
});
it("shows the user list", () => {
cy.get("[data-testid='user-row']").should("have.length.greaterThan", 0);
});
});
describe("Read-only viewer", () => {
beforeEach(() => {
cy.loginAs("viewer");
cy.visit("/dashboard");
});
it("hides admin links", () => {
cy.get("[data-testid='admin-link']").should("not.exist");
});
});Three roles, one cy.loginAs command, three independent cached sessions. Switching role across specs is the cost of one cookie-restore — not three logins.
The session lifecycle visualised
Step 1 of 5
Test 1 — first call
cy.session(['alice', 'pw'], setup) — no cache hit. Setup runs in full (UI or API login). Cookies + storage snapshotted.
Putting cy.session over API login
The fastest possible default — wrap API login in cy.session with cross-spec cache and validate:
Cypress.Commands.add("sessionLogin", (email, password) => {
cy.session(
[email, password],
() => {
cy.request("POST", "/api/login", { email, password })
.its("body.token")
.then((token) => cy.setCookie("auth_token", token));
},
{
cacheAcrossSpecs: true,
validate: () =>
cy.request("/api/users/me").its("status").should("eq", 200),
},
);
});Every test in every spec calls cy.sessionLogin(...). The first time, an HTTP login fires (~200 ms) and the cookie is cached. Every subsequent call restores in tens of milliseconds; tokens that expire mid-run are caught by validate and silently refreshed. This is the pattern most production Cypress suites converge on.
⚠️ Common mistakes
- Putting
cy.visitinside the session-setup callback.cy.session's job is to capture authentication state — cookies and storage — not to navigate. Acy.visit("/dashboard")inside the setup is wasted work; the session restore on the next test undoes the navigation anyway. Putcy.visitaftercy.sessionreturns. - Skipping
validateand watching tests flake on the first long-running CI build. Cached tokens go stale eventually. Withoutvalidate, the first test of the day on a session-heavy suite hits an expired token and fails for a reason that has nothing to do with the feature under test. Add a one-linevalidatefrom the start. - Using a session key that includes a timestamp.
cy.session([email, password, Date.now()], ...)— every test has a unique key, so nothing ever caches. The mistake usually comes from copy-pasting a unique-data-factory pattern. Keys must be deterministic per identity.
🎯 Practice task
Build a multi-role typed session library. 25-35 minutes.
- In
cypress/support/commands.ts, define aRole = "admin" | "standard" | "viewer"and acredentialsrecord (use Sauce Demo'sstandard_user/locked_out_user/problem_userif you don't have a real backend). - Implement
cy.loginAs(role: Role)that wrapscy.sessionwithcacheAcrossSpecs: trueand avalidatehook that does a cheap HTTP check (or, on Sauce Demo, asserts a known authenticated cookie). - Create two specs:
cypress/e2e/admin-panel.cy.tsandcypress/e2e/dashboard.cy.ts, each with threeitblocks. Both usecy.loginAs("admin")(or another role) inbeforeEach. - Run
npm run cy:run. Confirm the first test of the first spec triggers the full login (slow) and every test after — even across the spec boundary — restores in milliseconds. - Force a stale cache — between test 3 and test 4, manually corrupt the auth cookie (
cy.clearCookie("auth_token")). Confirmvalidatefires, fails, and Cypress re-runs the setup automatically. - Switch roles mid-spec — one test calls
cy.loginAs("admin"), the next callscy.loginAs("viewer"). Confirm Cypress maintains two independent caches and switches between them in milliseconds. - Stretch: add a
cy.logoutAndForgetSession()helper that clears cookies, localStorage, and callsCypress.session.clearAllSavedSessions(). Use it in anafterEachof one spec to verify the next test must re-run setup from scratch.
The next lesson tackles the harder cousin of regular login — OAuth and SSO, where the auth provider lives on a different origin and the standard session-caching pattern doesn't apply directly.