Reusing Login State with storageState
A login form is the slowest, least interesting part of almost every end-to-end suite, and running it before each test multiplies a four-second round trip by the number of tests you own. Playwright's storageState serialises an authenticated browser context — its cookies and per-origin localStorage — into a JSON file that any later context can load at creation time, so the browser starts already signed in. This page covers how to capture that file correctly, how to wire it into a project graph so every worker gets it, and how to diagnose the failures that appear when the saved state no longer matches what the application expects.
Root cause: a fresh context has no identity
Every Playwright test receives a brand-new browser context, which is an empty cookie jar, an empty localStorage and an empty cache. That isolation is the reason tests do not leak state into each other, but it also means the application has no way to recognise the browser, so it redirects to the login page. Repeating the login in beforeEach restores identity at the cost of running the slowest flow in the product on every test.
storageState breaks the dependency between "having an identity" and "performing a login". Playwright can serialise the two client-side stores a session actually lives in — cookies and localStorage, keyed per origin — and rehydrate them into a new context before the first navigation. The context is still isolated from every other context; it simply starts from a snapshot instead of from nothing. This page sits under Authentication & Session State within Playwright Setup & Core Architecture.
Minimal reproducible example
The pattern below is the one worth removing. It is correct and it passes, but the login runs once per test, and it re-runs on every retry as well.
import { test, expect } from '@playwright/test';
// Anti-pattern: the same login round trip executes before every single test.
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Password').fill(process.env.QA_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// The server sets a session cookie; the SPA also writes a JWT to localStorage.
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('shows the invoice table', async ({ page }) => {
await page.goto('/billing');
await expect(page.getByRole('table', { name: 'Invoices' })).toBeVisible();
});
test('shows the seat count', async ({ page }) => {
await page.goto('/team');
await expect(page.getByText('3 of 10 seats used')).toBeVisible();
});
What the state file actually contains
context.storageState() returns — and with a path option writes — a JSON document with exactly two top-level arrays. cookies holds every cookie the context accumulated, each with name, value, domain, path, expires, httpOnly, secure and sameSite. Session cookies are recorded with expires: -1 and are restored as session cookies. origins holds one entry per origin the context visited that wrote to localStorage, each carrying that origin's key/value pairs.
Everything else is absent. sessionStorage is deliberately excluded because its lifetime is the tab, not the profile. Service worker registrations, caches and in-memory JavaScript variables are gone too. IndexedDB is excluded by default; recent Playwright versions accept context.storageState({ path, indexedDB: true }) when an application keeps its refresh token there. If your login flow ends with data in any of those stores, restoring cookies alone will not produce a signed-in app.
The file is plain JSON, so read it rather than guessing when something breaks. Two fields explain most misbehaviour: expires, which tells you whether the session has already lapsed, and domain, which tells you whether the browser will attach the cookie to the host your tests navigate to. Also note what the shape implies about scope. Because state is captured per origin, a product spread across app.example.com and admin.example.com needs the setup test to visit both before saving, otherwise the second origin starts anonymous. The same rule applies to a local run served from a port that differs from the one used at capture time.
Step-by-step fix
- Write a dedicated setup file that performs one real login. Create
tests/auth.setup.ts, drive the actual form withgetByLabel()andgetByRole(), and assert on a post-login element before saving. Saving before the app has finished writing its token produces a file that looks valid and authenticates nothing. - Persist the context with
context.storageState({ path }). Call it onpage.context()at the end of the setup test and write toplaywright/.auth/user.json. The method resolves once the JSON has been flushed to disk, so no extra synchronisation is needed. - Register the setup as a project and depend on it. Add a project matching
/.*\.setup\.ts/and give every browser projectdependencies: ['setup']plususe: { storageState: STORAGE_STATE }. Playwright then runs setup to completion before any dependent project starts, in a single place rather than per file — the same project graph described in Setting Up an Auth Setup Project. - Add the auth directory to
.gitignore. The file contains live session credentials. Ignoreplaywright/.auth/and generate it fresh in CI/CD rather than caching it between pipeline runs, where it would outlive the session's server-side lifetime. - Opt individual tests out of the saved identity. Login, signup and permission-denied tests need an anonymous browser. Call
test.use({ storageState: { cookies: [], origins: [] } })inside adescribeblock to override the project default for those tests only. - Regenerate the state instead of trusting an old file. Treat the file as a build artefact of the current run. If a local run reuses a file older than the session TTL, delete it or guard the setup test so it re-authenticates when the cookie's
expirestimestamp has passed. - Reuse the same state for API calls.
request.newContext({ storageState })accepts the identical file, so fixtures that seed data over HTTP authenticate as the same user as the UI, keeping the two halves of a test consistent. - Split the file per role once permissions diverge. Write
admin.jsonandviewer.jsonfrom separate setup tests and point different projects at each, which is the basis for testing multiple user roles in parallel.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const STORAGE_STATE = 'playwright/.auth/user.json';
setup('authenticate as the standard user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.QA_EMAIL!);
await page.getByLabel('Password').fill(process.env.QA_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for a signal that the session is fully established. Saving before the
// SPA has written its token yields a file with cookies but no localStorage.
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Serialise cookies + per-origin localStorage to disk.
await page.context().storageState({ path: STORAGE_STATE });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const STORAGE_STATE = 'playwright/.auth/user.json';
export default defineConfig({
use: { baseURL: 'https://app.example.com' },
projects: [
// Runs first and produces the state file.
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'], // guarantees the file exists before tests run
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: STORAGE_STATE },
dependencies: ['setup'],
},
],
});
Troubleshooting variants
ENOENT: no such file or directory, open 'playwright/.auth/user.json'
The browser project started before anything produced the file. Almost always the dependencies: ['setup'] entry is missing, the testMatch pattern does not match the setup file's name, or the run was filtered — --project=chromium on its own still pulls in declared dependencies, but --grep filters can leave the setup test with nothing to execute. Verify the graph with npx playwright test --list, which prints the setup test above the dependent projects. A second cause is a relative path resolved from the wrong directory: storageState paths resolve against the current working directory, so prefer a path built from __dirname in monorepos.
Tests redirect to /login even though the file exists
The saved cookie is no longer accepted. Open the JSON and read the expires field: a Unix timestamp in the past means the session outlived its TTL, which happens whenever a state file is cached between CI runs. Compare domain against the host in your baseURL too — a cookie written for localhost is not sent to 127.0.0.1, and a cookie scoped to app.example.com is not sent to a preview deployment on a different subdomain unless it was set with a parent domain. Finally, servers that rotate a CSRF token or bind the session to a fingerprint will reject a replayed cookie by design; those applications need a fresh login per run, or an API-issued token injected with context.addCookies().
The app loads but behaves as though the user is anonymous
Cookies restored, token missing. Check whether the login writes anything into sessionStorage or IndexedDB, neither of which is in the default snapshot. Capture the missing values in the setup test and replay them with addInitScript(), which runs before any page script on every navigation in the context, or enable the indexedDB: true option when saving. If the value is a short-lived access token the application refreshes on boot, restoring it is usually unnecessary — restoring the long-lived refresh cookie is enough.
import { test, expect } from '@playwright/test';
// Restore a sessionStorage payload that storageState does not carry.
test.beforeEach(async ({ context }) => {
const session = JSON.parse(process.env.SESSION_SNAPSHOT ?? '{}');
// addInitScript runs before any page script, on every navigation.
await context.addInitScript((data: Record<string, string>) => {
for (const [key, value] of Object.entries(data)) {
window.sessionStorage.setItem(key, value);
}
}, session);
});
test('renders the workspace switcher for a signed-in user', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('button', { name: 'Switch workspace' })).toBeVisible();
});
Verification
Prove the state is doing real work rather than accidentally passing. First, delete playwright/.auth/user.json and run a single test with --project=chromium; the run should recreate the file through the setup dependency and still pass, which confirms the graph, not a stale artefact, is producing the identity. Second, inspect the file after a run: it should contain at least one cookie for your application's domain and, for a single-page app, a matching entry under origins. An origins array of length zero on a token-based app is the signature of a snapshot taken too early.
Third, add a negative test. Wrap a describe block in test.use({ storageState: { cookies: [], origins: [] } }), navigate to a protected route and assert that the login form appears. If that test also lands on the dashboard, some other mechanism — a shared server session, a default user in a seeded database — is authenticating your tests and the saved state is not being exercised at all. Finally, open a trace in the Playwright Trace Viewer and read the first request's Cookie header in the network tab; seeing the session cookie on the very first navigation is direct evidence the state loaded before page load. Suites that authenticate this way also lose a common source of intermittent failure, which is worth knowing when triaging flaky tests.
Frequently Asked Questions
Is a shared state file safe when tests run in parallel?
Yes, because each worker only reads it. Playwright loads the JSON when it creates a context and copies the values into that context's own cookie jar, so no two workers share mutable state and nothing writes back to disk during the run. Problems only appear if a test itself calls storageState({ path }) against the shared path mid-run, which introduces a genuine write race — save to a per-worker path if you need that. The trade-off is that all workers act as the same user, so tests that mutate account-level data still need separate accounts.
Should I commit the state file to version control?
No. It holds a live session cookie and often a bearer token, which is a credential with the same power as a password for as long as the session lasts. Add playwright/.auth/ to .gitignore and let the setup project regenerate it on every machine and every pipeline. Caching it between CI runs to save time backfires as soon as the server-side session expires, producing failures that look like application bugs.
How does storageState differ from a global setup script?
A globalSetup function runs once in Node before the test runner starts and has no fixtures, so you must launch a browser by hand and manage its lifetime yourself. A setup project is an ordinary test file: it gets the page fixture, appears in the HTML report, records a trace on failure, and honours retries. Both can produce the same JSON, but the project approach gives you the debugging surface of a normal test, which matters when the login flow breaks. Expensive non-auth setup can also live in worker-scoped fixtures.
Can I reuse the same state for an SSO or OAuth login?
Usually yes, since the identity provider's redirect ends with your own application setting a session cookie, and that cookie is what gets saved. The complication is that many providers block automated sign-in or require a second factor, so the setup test needs a test-only account or a bypass. The specifics are covered in handling OAuth and SSO redirects, and the same snapshot technique underpins scraping data behind login sessions.