If every test in a 200-test suite logs in through the UI, you spend five seconds × 200 = nearly 17 minutes per CI run on the same login form. Multiply across three browsers and the cost is an hour. The fix is storage state — log in once, save the resulting cookies and localStorage to a JSON file, and load that file into every test's BrowserContext. Tests start already authenticated; the login flow is exercised by exactly one spec; the suite shrinks to its actual cost. This lesson is the canonical Playwright auth pattern: setup project + storage state file + per-project use.storageState. By the end you'll know how to handle multi-role suites, API-based fast-path login, and the security rules around the auth files.
What storageState actually is
A storage state file is a JSON snapshot of:
- Cookies — every cookie set on the BrowserContext, with domain, path, expiry, secure/HttpOnly flags.
- localStorage — the localStorage of every origin the context has visited.
That's it. No DOM, no service workers, no IndexedDB by default. For most session-based auth (cookie sessions, JWT-in-cookie, JWT-in-localStorage), this is exactly what you need.
// Save the current context's auth state
await page.context().storageState({ path: "tests/.auth/user.json" });// Load it into a new BrowserContext
const context = await browser.newContext({ storageState: "tests/.auth/user.json" });Or, more commonly, set it project-wide so every test inherits the auth:
{
name: "user",
use: { storageState: "tests/.auth/user.json" }
}The setup project pattern
The recommended way to produce the storage state file is via a setup project — a Playwright project that contains the login spec, marked to run before every other project:
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
const userAuthFile = "tests/.auth/user.json";
setup("authenticate as user", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("Sup3rS3cret!");
await page.getByRole("button", { name: "Sign in" }).click();
// Sanity-check that login actually worked before saving state
await expect(page).toHaveURL(/dashboard/);
await page.context().storageState({ path: userAuthFile });
});// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "setup",
testMatch: /.*\.setup\.ts/
},
{
name: "chromium",
testDir: "./tests/authenticated",
use: {
...devices["Desktop Chrome"],
storageState: "tests/.auth/user.json"
},
dependencies: ["setup"]
},
{
name: "guest",
testDir: "./tests/guest"
// No storageState — runs unauthenticated
}
]
});The dependencies: ['setup'] arrow is what makes the order work. Playwright runs the setup project first; only after every spec in it passes does the chromium project start. Specs in the chromium project see the freshly-written user.json and load it into their BrowserContext automatically.
How a test sees the state
A test in the chromium project doesn't have to do anything special — it's already logged in:
// tests/authenticated/dashboard.spec.ts
import { test, expect } from "@playwright/test";
test("user lands on dashboard with correct welcome message", async ({ page }) => {
await page.goto("/dashboard"); // already authenticated; no login click needed
await expect(page.getByRole("heading")).toContainText("Welcome, Alice");
});The goto('/dashboard') succeeds because the cookies in user.json were loaded into the BrowserContext before the test ran. There's no login form, no await page.getByLabel('Email').fill(...). The test starts at the destination.
Multi-role suites — one storage state per role
Real apps have admin users, regular users, read-only viewers, and so on. Each role has its own permissions and its own UI. Each role gets its own setup spec and its own state file:
// tests/auth.setup.ts
import { test as setup } from "@playwright/test";
setup("authenticate as admin", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("admin@test.com");
await page.getByLabel("Password").fill("AdminPass");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/admin/);
await page.context().storageState({ path: "tests/.auth/admin.json" });
});
setup("authenticate as user", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("UserPass");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/dashboard/);
await page.context().storageState({ path: "tests/.auth/user.json" });
});
setup("authenticate as viewer", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("viewer@test.com");
await page.getByLabel("Password").fill("ViewerPass");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/reports/);
await page.context().storageState({ path: "tests/.auth/viewer.json" });
});The two setup specs run in parallel (Playwright runs setup specs in parallel by default), so producing three role files takes the same time as producing one.
Then the project graph:
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "admin-tests",
testDir: "./tests/admin",
use: { storageState: "tests/.auth/admin.json" },
dependencies: ["setup"]
},
{
name: "user-tests",
testDir: "./tests/user",
use: { storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
{
name: "viewer-tests",
testDir: "./tests/viewer",
use: { storageState: "tests/.auth/viewer.json" },
dependencies: ["setup"]
},
{
name: "guest-tests",
testDir: "./tests/guest"
}
];Four projects run after setup, in parallel. Each one has tests pre-authenticated as the right role. Adding a fifth role means one new setup spec + one new project — three lines of config.
API-based auth — even faster
UI login takes 1-3 seconds even for a simple form. If your backend exposes a login API, skip the UI entirely:
// tests/auth.setup.ts
import { test as setup } from "@playwright/test";
setup("authenticate via API", async ({ playwright }) => {
const apiContext = await playwright.request.newContext({
baseURL: "http://localhost:3000"
});
const loginRes = await apiContext.post("/api/login", {
data: { email: "alice@test.com", password: "Sup3rS3cret!" }
});
expect(loginRes.ok()).toBeTruthy();
// Save the resulting cookies + tokens
await apiContext.storageState({ path: "tests/.auth/user.json" });
await apiContext.dispose();
});No browser launches. No DOM rendering. The whole setup takes 50-200ms, not 2-3 seconds. The resulting user.json is functionally identical to the UI version — same cookies, same session.
When this works, prefer it. The UI login spec then becomes a one test in your suite (the one that explicitly tests the login flow), not a setup ritual every test pays for.
The .gitignore rule
A storage state file contains real session cookies. If you push it, you've published live auth credentials to the world.
# .gitignore
tests/.auth/
The setup project regenerates the files on every CI run; you never need them in git. If you accidentally commit one, treat it like a credential leak — rotate the affected user's session, rewrite git history if the repo is public.
The full lifecycle
Step 1 of 5
Test run starts
npx playwright test — Playwright resolves the project graph. The 'setup' project is first because other projects depend on it.
A complete e-commerce config with auth setup
Putting every chapter-5 and chapter-6 pattern together:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [["html"], ["list"]],
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure"
},
projects: [
// Auth setup — runs first
{ name: "setup", testMatch: /.*\.setup\.ts/ },
// Authenticated suites per browser
{
name: "user-chromium",
testDir: "./tests/user",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
{
name: "user-firefox",
testDir: "./tests/user",
use: { ...devices["Desktop Firefox"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
{
name: "admin-chromium",
testDir: "./tests/admin",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/admin.json" },
dependencies: ["setup"]
},
// Mobile coverage on user surface
{
name: "user-mobile",
testDir: "./tests/user",
use: { ...devices["iPhone 13"], storageState: "tests/.auth/user.json" },
dependencies: ["setup"]
},
// Guest tests — no auth needed
{ name: "guest", testDir: "./tests/guest" }
]
});Five test projects + setup. Same auth setup feeds four authenticated projects. A 200-test user-flow suite that previously took 17 minutes (3 minutes × 5 browsers and 2 minutes of login overhead per browser) drops to closer to 5 minutes — same coverage, no per-test login cost.
Refreshing stale auth
Auth tokens expire. Storage state files captured a week ago may have stale sessions. The setup project pattern handles this for you — every CI run regenerates the file, so the auth is always fresh.
For local dev, if you want to skip running setup every time:
npx playwright test --project=user-chromiumThe dependencies chain still triggers setup if user-chromium declares it. To bypass when you know the auth is current, pass --no-deps:
npx playwright test --project=user-chromium --no-depsUse sparingly — stale auth is one of the more confusing test-failure modes ("the test was logged in last week, why does it say 'redirect to login' now?").
Coming from Cypress?
The mappings:
cy.session('alice', () => { /* login flow */ })— caches in memory per worker, restored at test start.- Playwright's
storageState— persisted to disk; loaded into BrowserContext at test start.
Cypress's cy.session is functionally similar but in-memory; it doesn't survive across runs. Playwright's storage state survives across the run (regenerated by setup) and across local debugging (you can keep a state file for hours of local work without re-logging-in). The migration shape: each cy.session block becomes a setup spec; each Cypress.session.clearAllSavedSessions() becomes "delete the state file or run setup again."
⚠️ Common mistakes
- Reusing one
user.jsonacross tests that mutate user state. Test A logs out the user; the storage state inuser.jsonwas captured before logout, so test B still loads valid cookies — but maybe a piece of localStorage was nuked by test A. Per-test state isolation is what BrowserContexts give you; the storage state file is a starting point, not a shared mutable resource. - Skipping
expect(page).toHaveURL(...)after login in setup. If the login click silently fails (wrong password, server error),storageStatesaves an unauthenticated state. Every test then starts un-logged-in and fails confusingly. Always assert the post-login URL or a known authenticated element before saving. - Storing
storageStatefiles outside.gitignore. Once committed, the cookies are public. Setup specs regenerate the files on every CI run; there's no reason to commit them. If you must share authentication for a debugging session with a teammate, share the file out-of-band (encrypted Slack DM, password manager) — never via git.
🎯 Practice task
Wire up storage-state auth for a Sauce Demo suite. 30-40 minutes.
-
Create
tests/auth.setup.ts:import { test as setup, expect } from "@playwright/test"; const standardUserAuth = "tests/.auth/standard-user.json"; const problemUserAuth = "tests/.auth/problem-user.json"; setup("authenticate as standard_user", async ({ page }) => { await page.goto("https://www.saucedemo.com"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await expect(page).toHaveURL(/inventory/); await page.context().storageState({ path: standardUserAuth }); }); setup("authenticate as problem_user", async ({ page }) => { await page.goto("https://www.saucedemo.com"); await page.getByPlaceholder("Username").fill("problem_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await expect(page).toHaveURL(/inventory/); await page.context().storageState({ path: problemUserAuth }); }); -
Update
playwright.config.ts:import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests", use: { baseURL: "https://www.saucedemo.com", trace: "on-first-retry" }, projects: [ { name: "setup", testMatch: /.*\.setup\.ts/ }, { name: "standard-user", testDir: "./tests/standard", use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/standard-user.json" }, dependencies: ["setup"] }, { name: "problem-user", testDir: "./tests/problem", use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/problem-user.json" }, dependencies: ["setup"] }, { name: "guest", testDir: "./tests/guest", use: { ...devices["Desktop Chrome"] } } ] }); -
Add
tests/.auth/to.gitignore. -
Create
tests/standard/inventory.spec.ts,tests/problem/inventory.spec.ts,tests/guest/login.spec.ts:// tests/standard/inventory.spec.ts import { test, expect } from "@playwright/test"; test("standard user — sees 6 items", async ({ page }) => { await page.goto("/inventory.html"); await expect(page.locator(".inventory_item")).toHaveCount(6); });// tests/problem/inventory.spec.ts import { test, expect } from "@playwright/test"; test("problem user — broken images render with alt text", async ({ page }) => { await page.goto("/inventory.html"); // problem_user shows broken images on Sauce Demo by design await expect(page.locator(".inventory_item")).toHaveCount(6); });// tests/guest/login.spec.ts import { test, expect } from "@playwright/test"; test("guest — sees the login form", async ({ page }) => { await page.goto("/"); await expect(page.getByPlaceholder("Username")).toBeVisible(); }); -
Run
npx playwright test. Watch the order: setup runs (logging in twice in parallel), then standard-user, problem-user, and guest projects run in parallel. -
Verify the speedup. Time the run. Then add a
console.log('logging in')in the standard-user spec's first test (note: there's no login click — it's already authenticated). Confirm the message prints once per test, not "logging in" with credential typing — the storage state worked. -
Force a stale-auth scenario. Open
tests/.auth/standard-user.json, change the value of any cookie to a random string, save. Re-run--project=standard-user --no-deps(skip setup). The test fails because the saved cookie is invalid. Run without--no-deps— the setup regenerates the file and tests pass. This is the muscle for "always re-run setup if you suspect stale auth." -
Stretch: convert the auth setup to API-based. Sauce Demo doesn't expose a real login API, so use
https://reqres.in/api/loginas a stand-in. TheapiContext.storageState({ path })call works the same way. Time the new setup — typically under 200ms vs 2-3 seconds for UI auth.
You've now removed the single biggest source of wasted CI time on most Playwright suites. The next and final lesson of this chapter handles the last pattern teams routinely run into — testing modern web components with Shadow DOM.