Skip to content

Log in once, not 100 times: Playwright storageState

Updated 2026-09-28
1
login per run
~10 min
saved: 100 tests × 6 s, serial
0
new dependencies

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.

  1. Setup project runsauth.setup.ts is matched by the setup project and runs before any test project.
  2. Log in through the UIOne real login, then wait for proof the session exists — the dashboard heading.
  3. Save the statepage.context().storageState({ path }) writes cookies + localStorage to playwright/.auth/user.json.
  4. Projects depend on setupdependencies: ['setup'] guarantees the file exists before tests start.
  5. 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.

Before
After

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.

Login time per run (wall-clock)
Before · 100 logins2m 30s
After · 1 login6s
2m 24s
faster feedback, every run
49.5h
CI machine time saved per month

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.

tests/auth.setup.tsTypeScript
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.

playwright.config.tsTypeScript
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.

Terminal
mkdir -p playwright/.auth
echo 'playwright/.auth' >> .gitignore

Watch 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.

tests/login.spec.tsTypeScript
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.
session storage workaroundTypeScript
// 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.

fixtures/auth.tsTypeScript
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.

tests/auth.setup.tsTypeScript
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