Log in once, not 100 times: Playwright storageState
Playwright's storageState saves a logged-in browser's cookies and localStorage (and IndexedDB on request) to a JSON file. A setup project logs in once per run, writes playwright/.auth/user.json, and every test starts already authenticated through use: { storageState }. Keep dedicated login tests, add the auth folder to .gitignore, and give each worker its own account when tests change server-side state.
Most suites start the same way: open the app, type a username, type a password, click Sign in, wait for the dashboard — and only then test the thing the test is actually about. Multiply that by 100 or 500 tests and login becomes the most-executed and least-interesting part of your pipeline. storageState is Playwright's built-in fix. It takes about fifteen lines of code and no new dependencies.
Key takeaways
- Log in once in a setup project, save the state with storageState(), and point every test project at the file with use: { storageState }.
- storageState covers cookies and localStorage; IndexedDB needs indexedDB: true; sessionStorage is never saved — restore it yourself with addInitScript.
- The saved file holds live session tokens: add playwright/.auth to .gitignore.
- With parallel workers the wall-clock saving is divided by the worker count, but the CI machine time you stop paying for is not.
- Keep a few dedicated login tests that opt out of the saved state — the login flow still deserves coverage.
How it works
The setup project runs first, once. Every other project depends on it and loads the file it wrote.
- Setup project runsauth.setup.ts is matched by the setup project and runs before any test project.
- Log in through the UIOne real login, then wait for proof the session exists — the dashboard heading.
- Save the statepage.context().storageState({ path }) writes cookies + localStorage to playwright/.auth/user.json.
- Projects depend on setupdependencies: ['setup'] guarantees the file exists before tests start.
- Tests start logged inEvery new browser context loads the file, so page.goto('/dashboard') just works.
What changes
Click a row with code to compare the two approaches.
Click any row to see the code change
Calculate your own saving
Move the sliders to your suite. Wall-clock is what a pipeline waits for; machine time is what your CI minutes pay for.
Assumes logins are spread evenly across workers and the setup login runs once per run. Real suites also skip the page loads around each login, so the true saving is usually a bit higher.
Step 1 — Log in once in a setup file
Create a setup test that performs one real login and saves the result. Wait for something that only a logged-in user sees before saving; otherwise you can save a state captured a moment before the session cookie arrives.
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_USER!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for proof that the session cookie is set before saving it.
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});Step 2 — Wire it into playwright.config.ts
Declare the setup project, then make each browser project depend on it and load the saved file. Dependencies run first on every npx playwright test, so the state is always fresh for that run.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: { baseURL: process.env.BASE_URL },
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});Step 3 — Keep the auth file out of git
The JSON file contains real session cookies and tokens. Anyone with the file can act as that user until the session expires.
mkdir -p playwright/.auth
echo 'playwright/.auth' >> .gitignoreWatch out: Use a dedicated test account with test-only permissions. Never save the state of a real admin or a personal account.
Tests that must start logged out
Login, logout, password-reset and 'session expired' tests are about authentication, so they should not inherit the saved state. Override it for that file with an empty state.
import { test, expect } from '@playwright/test';
// These tests are *about* logging in, so they start logged out.
test.use({ storageState: { cookies: [], origins: [] } });
test('wrong password shows an error', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('nobody@example.com');
await page.getByLabel('Password').fill('wrong-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText(/invalid email or password/i);
});What storageState saves — and what it doesn't
It is a snapshot of browser storage, not of your app's memory. Know the edges before you rely on it.
- Cookies — saved.
- localStorage — saved, per origin.
- IndexedDB — only with storageState({ path, indexedDB: true }) (Playwright 1.51+). Firebase-style auth often lives here.
- sessionStorage — never saved. If your app keeps its token there, save it to a separate session.json and restore it with addInitScript (below).
- In-memory state (a token held in a JS variable) — cannot be saved; the app has to re-derive it from cookies or storage on load.
// Save (after logging in)
const session = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('playwright/.auth/session.json', session, 'utf-8');
// Restore into a new context before any page script runs
const saved = JSON.parse(fs.readFileSync('playwright/.auth/session.json', 'utf-8'));
await context.addInitScript((storage: Record<string, string>) => {
if (window.location.hostname === 'app.example.com') {
for (const [key, value] of Object.entries(storage)) window.sessionStorage.setItem(key, value);
}
}, saved);One account per worker when tests change server state
A shared account is fine while tests only read. Once tests change settings, carts or profiles on the server, parallel workers will overwrite each other. Give each worker its own account with a worker-scoped fixture keyed on parallelIndex.
import fs from 'node:fs';
import path from 'node:path';
import { test as baseTest, expect } from '@playwright/test';
export const test = baseTest.extend<{}, { workerStorageState: string }>({
// Every test in this worker reuses the worker's own saved session.
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [async ({ browser }, use) => {
const id = test.info().parallelIndex;
const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);
if (fs.existsSync(fileName)) {
await use(fileName);
return;
}
// Start from a clean context, not from another saved state.
const page = await browser.newPage({ storageState: undefined });
await page.goto(`${process.env.BASE_URL}/login`);
await page.getByLabel('Email').fill(`qa.worker${id}@example.com`);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
}, { scope: 'worker' }],
});Tip: Playwright 1.63 added test locks — test('…', { lock: 'user-settings' }, …) — for the few tests that must never run at the same time even on separate accounts.
Faster still: log in through the API
If your app has a login endpoint, the setup step does not need a browser at all. The request fixture keeps cookies from the response and can save them the same way.
import { test as setup } from '@playwright/test';
setup('authenticate via API', async ({ request }) => {
await request.post('/api/login', {
form: { email: process.env.E2E_USER!, password: process.env.E2E_PASSWORD! },
});
await request.storageState({ path: 'playwright/.auth/user.json' });
});When the saved state goes stale
Because the setup project runs at the start of every run, the file is rebuilt each time and short-lived tokens are rarely a problem in CI. Locally, if you run a single test with --no-deps and it lands on the login page, the saved session has expired — run the setup again. If a session outlives one run by design, add an expiry check in the setup and reuse the file only while it is valid.
FAQ
A JSON snapshot of a browser context's cookies and localStorage (and IndexedDB when requested). You save it with context.storageState({ path }) and load it into new contexts with use: { storageState } or browser.newContext({ storageState }), so tests start already authenticated.
Sources
Related tools
Related guides
How to Build a Scalable Playwright Framework in TypeScript (2026)
A step-by-step blueprint for a maintainable Playwright + TypeScript framework: folder structure you can click through, config, page objects, fixtures, test data, API setup, storageState auth and a sharded GitHub Actions pipeline.
Playwright Cheat Sheet 2026: Commands, Locators & Assertions (TS + Python)
A searchable Playwright quick reference with 134 copy-ready snippets in 26 sections — install, test structure, locators, actions, assertions, waits, frames, dialogs, popups, files, storage, API testing, emulation, traces, config, annotations and the CLI — in TypeScript and Python, for Playwright 1.63.
Playwright Complete Notes: Beginner to Advanced (TypeScript, 2026)
A 14-chapter Playwright course in TypeScript — architecture, setup, locators, auto-waiting, actions, frames and popups, waits, API mocking, fixtures, debugging, 2026 features and interview questions — with runnable code, self-checks and progress tracking.