Resource library

QA How-To

Playwright storageState reuse: Examples and Best Practices

Explore playwright storageState reuse examples for UI and API login, multiple roles, worker accounts, IndexedDB, session expiry, and secure CI execution.

20 min read | 2,345 words

TL;DR

The core pattern is setup -> authenticate -> wait for a stable signed-in signal -> save storageState -> load it through use.storageState. Expand it with separate role files, worker-scoped accounts, IndexedDB capture, or an anonymous override according to the suite's isolation needs.

Key Takeaways

  • Use a setup project for normal shared authentication because dependencies, assertions, traces, and reports stay visible.
  • Select UI or API login based on the behavior under test, then verify the saved state on a protected route.
  • Create separate least-privilege state files for roles and separate accounts for mutating parallel workers.
  • Pass indexedDB: true only when the application stores required authentication material there.
  • Reset cookies and origins for anonymous coverage, and restore sessionStorage through an origin-restricted init script.
  • Make state freshness, secure disposal, artifact policy, and failure diagnosis explicit in CI.

These Playwright storageState reuse examples cover the patterns teams actually need: a setup project with UI login, a faster API login, multiple roles, two actors in one test, one account per worker, IndexedDB capture, signed-out overrides, sessionStorage restoration, and state refresh. Each pattern keeps a new BrowserContext isolated while supplying the authentication material required at startup.

Choose examples based on backend behavior, not only test speed. One shared snapshot is efficient for read-only tests, but it does not isolate a user's server-side cart, settings, or records. The safest implementation uses the least privileged identity and the narrowest reuse scope that still removes repetitive login.

TL;DR

// Save after a verified login.
await page.context().storageState({
  path: 'playwright/.auth/user.json',
});

// Reuse through project configuration.
use: {
  storageState: 'playwright/.auth/user.json',
}
Pattern Best fit Main caution
Shared setup state Independent read-only tests Backend account is still shared
API-created state Feature suites with supported login API Browser must accept the same session
One file per role Authorization coverage Use separate least-privilege accounts
One file per worker Mutating parallel suites Account allocation must be atomic
IndexedDB snapshot Auth library stores tokens there Capture only required data
Runtime empty state Anonymous scenarios Clear both cookies and origins
sessionStorage init script Rare sessionStorage auth Restrict restoration to one hostname

1. Basic playwright storageState reuse examples with UI login

Start with a setup test that behaves like a user, asserts successful authentication, and saves only after redirects and client initialization finish:

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';

const authFile = path.join(
  process.cwd(),
  'playwright',
  '.auth',
  'user.json'
);

setup('authenticate standard user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Work email').fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL(/\/workspace$/);
  await expect(page.getByRole('button', { name: 'User menu' }))
    .toBeVisible();

  await page.context().storageState({ path: authFile });
});

The URL and User menu are application contracts, so replace them with stable signals from the product. Waiting for an authenticated element helps ensure the front end has consumed the session. The final URL helps ensure redirect-set cookies have arrived.

Do not assert a token value or print cookies. Authentication traces can contain sensitive request details, so control access and retention. Keep playwright/.auth in .gitignore before the first state file is created.

UI login is the right example when the environment lacks a supported API flow or when the setup itself needs to exercise a federated redirect. It is not a substitute for focused authentication tests. Feature tests that load this state do not cover login form validation, MFA, lockout, or logout.

2. Connect the setup project to browser projects

Define a setup project and make consumer projects depend on it:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'auth-setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['auth-setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['auth-setup'],
    },
  ],
});

Now a feature spec needs no login helper:

import { test, expect } from '@playwright/test';

test('opens the saved projects page', async ({ page }) => {
  await page.goto('/projects');
  await expect(page.getByRole('heading', { name: 'Projects' }))
    .toBeVisible();
});

The dependency keeps setup in normal Playwright reporting and allows it to use fixtures, assertions, retries, and traces. A generic globalSetup function can create state, but the project pattern is easier to observe and maintain.

Each test gets a new context initialized from user.json. The original setup page is not reused. Client storage changes made by a test do not rewrite the JSON file. Backend changes made by the account remain shared and must be isolated through data design.

For the full reasoning and security model, see how to use Playwright storageState reuse.

3. Create authentication state through an API

When the login service exposes a supported endpoint, the request fixture can establish cookies and save state:

// tests/api-auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate through API', async ({ request }) => {
  const response = await request.post('/api/sessions', {
    data: {
      email: process.env.E2E_USER_EMAIL,
      password: process.env.E2E_USER_PASSWORD,
    },
  });

  expect(response.status()).toBe(201);
  await request.storageState({
    path: 'playwright/.auth/user.json',
  });
});

/api/sessions and its 201 response are illustrative product contracts, not Playwright APIs. Adapt them to the documented service. Do not create an unprotected backdoor only for browser tests.

Add a small browser smoke test that opens a protected route. An API may return success while storing a token in a response body that the browser never receives. request.storageState is useful when the endpoint sets cookies or compatible origin state.

API setup removes identity-provider rendering, animation, and external UI from every feature run. Keep at least one UI authentication suite for the sign-in experience and identity integration. That division gives faster features without losing login coverage.

The request fixture shares cookie handling within its context. If setup code creates a new request context manually, pass storageState: undefined when it must be clean and call dispose after saving. Review Playwright APIRequestContext examples for service-level provisioning and teardown.

4. Generate and select multiple role states

Save separate files for admin and viewer accounts. A small helper can remove duplication without hiding the login assertions:

// tests/roles.setup.ts
import { test as setup, expect, type Page } from '@playwright/test';

async function signIn(
  page: Page,
  email: string,
  password: string,
  expectedRole: string,
) {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByTestId('active-role')).toHaveText(expectedRole);
}

setup('save admin state', async ({ page }) => {
  await signIn(
    page,
    process.env.E2E_ADMIN_EMAIL!,
    process.env.E2E_ADMIN_PASSWORD!,
    'Admin',
  );
  await page.context().storageState({
    path: 'playwright/.auth/admin.json',
  });
});

setup('save viewer state', async ({ page }) => {
  await signIn(
    page,
    process.env.E2E_VIEWER_EMAIL!,
    process.env.E2E_VIEWER_PASSWORD!,
    'Viewer',
  );
  await page.context().storageState({
    path: 'playwright/.auth/viewer.json',
  });
});

Map role-focused projects to these paths or apply a file-level test.use override. Separate accounts prevent an admin permission from making a viewer test pass accidentally. They also avoid racing role changes on one mutable account.

Use role projects when whole groups of specs share identity. Keep project names visible in reports so a failing authorization check communicates which role and browser produced it. Avoid generating every role before a small filtered run if startup cost is material; split setup dependencies by consumer where appropriate.

5. Use two authenticated actors in one test

An approval workflow may need an author and an approver simultaneously. Create two contexts from the browser fixture:

import { test as base, expect, type Page } from '@playwright/test';

type RolePages = {
  adminPage: Page;
  viewerPage: Page;
};

const test = base.extend<RolePages>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/admin.json',
    });
    await use(await context.newPage());
    await context.close();
  },
  viewerPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/viewer.json',
    });
    await use(await context.newPage());
    await context.close();
  },
});

test('admin can review viewer submission', async ({
  adminPage,
  viewerPage,
}) => {
  await viewerPage.goto('/submissions/new');
  await viewerPage.getByLabel('Title').fill('Quarterly evidence');
  await viewerPage.getByRole('button', { name: 'Submit' }).click();

  await adminPage.goto('/review-queue');
  await expect(adminPage.getByText('Quarterly evidence')).toBeVisible();
});

Fixture teardown closes both contexts even when the assertion fails. Each page has independent cookies and local storage. The shared submission is intentional business state created inside the test.

Use unique record names when tests run in parallel. If the approval queue is eventually consistent, wait for a supported UI status or poll the service with a bounded expectation. Do not add a fixed sleep.

This fixture pattern is more expressive than switching storage in one page mid-test. It models two concurrent users and preserves a clear audit trail in actions and traces.

6. Build one state file per parallel worker

When tests mutate a user's preferences, cart, or one-per-user resource, allocate accounts at worker scope:

// playwright/fixtures.ts
import { test as base, request } from '@playwright/test';
import path from 'node:path';

export const test = base.extend<{}, { workerState: string }>({
  storageState: ({ workerState }, use) => use(workerState),

  workerState: [async ({}, use) => {
    const id = base.info().parallelIndex;
    const file = path.resolve(
      base.info().project.outputDir,
      '.auth',
      `worker-${id}.json`,
    );
    const account = await acquireAccount(id);
    const api = await request.newContext({
      baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
      storageState: undefined,
    });

    await api.post('/api/sessions', {
      data: {
        email: account.email,
        password: account.password,
      },
    });
    await api.storageState({ path: file });
    await api.dispose();

    await use(file);
    await releaseAccount(account);
  }, { scope: 'worker' }],
});

acquireAccount and releaseAccount belong to the test platform. They must reserve accounts atomically across simultaneous workflows. parallelIndex is useful for identity inside Playwright, but index 2 can exist in several pipelines at once.

The project output directory is cleaned and scoped to execution, making it safer than a long-lived shared file. The storageState fixture depends on workerState, so tests in one worker reuse its authenticated file.

Account teardown should tolerate a failed or interrupted test. If the CI system can terminate workers abruptly, use leases that expire and a periodic reset service. Never make the entire suite depend on a manually maintained spreadsheet of account availability.

Worker authentication pairs naturally with Playwright sharding across machines best practices, because total account demand comes from shards multiplied by workers and overlapping runs.

7. More playwright storageState reuse examples for IndexedDB

If the application stores authentication in IndexedDB, include it at capture time:

setup('save Firebase-backed session', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByText('Signed in')).toBeVisible();

  await page.context().storageState({
    path: 'playwright/.auth/user-with-indexeddb.json',
    indexedDB: true,
  });
});

IndexedDB capture is supported by current BrowserContext.storageState. The filename communicates that the snapshot has broader contents, which helps reviewers apply appropriate protection.

Verify need instead of enabling the option everywhere. Inspect the application's storage design and run a protected-route test with and without it. A state file with extra data may be larger and may include identifiers unrelated to authentication.

Do not manually edit IndexedDB structures in the serialized file to create roles or expiry. Authenticate through supported contracts so server and client agree. For token-expiry scenarios, use a controlled short-lived account or application test hook reviewed by the security team.

Remember that sessionStorage is separate and still not captured by indexedDB: true. The storage mechanism named in the application architecture determines the restoration method.

8. Override state for anonymous and logout coverage

An authenticated project can contain a file that explicitly starts empty:

// tests/anonymous.spec.ts
import { test, expect } from '@playwright/test';

test.use({
  storageState: {
    cookies: [],
    origins: [],
  },
});

test('anonymous visitor cannot open billing', async ({ page }) => {
  await page.goto('/billing');
  await expect(page).toHaveURL(/\/login/);
  await expect(page.getByRole('heading', { name: 'Sign in' }))
    .toBeVisible();
});

Clear both cookies and origins. This override replaces the project's authenticated seed for tests in the file. A separate anonymous project is cleaner if many specs require signed-out behavior.

A logout test should start from saved authenticated state, click the real logout control, and prove a protected page redirects:

test('logout invalidates the browser session', async ({ page }) => {
  await page.goto('/account');
  await page.getByRole('button', { name: 'User menu' }).click();
  await page.getByRole('menuitem', { name: 'Sign out' }).click();
  await page.goto('/account');
  await expect(page).toHaveURL(/\/login/);
});

The logout affects only that test's context and server session. It does not rewrite the source state file. If the server invalidates all sessions for the user, a shared account can break other parallel tests, so isolate destructive authentication scenarios in a separate account or project.

9. Restore sessionStorage and apply state at runtime

sessionStorage is not part of storageState. Capture it after login and restore before page scripts run:

import fs from 'node:fs';

const json = await page.evaluate(() =>
  JSON.stringify(window.sessionStorage)
);
fs.writeFileSync('playwright/.auth/session.json', json, 'utf8');
const saved = JSON.parse(
  fs.readFileSync('playwright/.auth/session.json', 'utf8')
);

await context.addInitScript(storage => {
  if (window.location.hostname === 'app.example.test') {
    for (const [key, value] of Object.entries(storage)) {
      window.sessionStorage.setItem(key, String(value));
    }
  }
}, saved);

Protect session.json like every other auth artifact. The hostname condition is essential because an init script runs for pages in the context. Replace app.example.test with the exact application host.

Current Playwright also supports applying saved state to an existing BrowserContext:

await context.setStorageState('playwright/.auth/user.json');
const page = await context.newPage();
await page.goto('/workspace');

This is useful for a controlled identity transition or a library-level scenario. For ordinary Playwright Test suites, configure storageState before context creation. Starting in the correct identity is simpler and avoids pages observing a partial transition.

Do not use runtime switching to avoid designing role fixtures. Existing server connections, application memory, or open pages may still represent the previous user. Close pages or create a new context when a clean security boundary matters.

10. Verify freshness and operate securely in CI

CI should usually generate fresh state in each shard job. This repeats setup but avoids transferring a sensitive artifact and prevents one expired file from poisoning many jobs. API login can keep the cost small.

Add a fast protected-route assertion immediately after setup or as the first dependency smoke test. When it fails, report nonsecret provenance:

  • Setup project name.
  • Environment and base URL.
  • Account alias, not password or token.
  • Browser project.
  • CI run and shard identity.
  • State creation timestamp.

Never log cookies, authorization headers, localStorage values, or the JSON file. If a state artifact must cross jobs, encrypt it through the CI platform, restrict readers, use minimal retention, and delete it after consumers finish.

Do not treat file existence as freshness. Cookies may be expired or revoked, and the file may target another domain. Regenerate at the setup boundary rather than adding silent login recovery to feature tests. Silent recovery turns an authentication outage into scattered delays and hides session-expiry defects.

In UI mode, developers may need to run the setup project manually after local state expires. Document the exact command:

npx playwright test --project=auth-setup

The project name must match config. After regeneration, run one protected smoke test before a long debug session. For failure evidence, use Playwright Trace Viewer debugging with access controls appropriate for authentication traffic.

Treat setup as a monitored dependency rather than invisible boilerplate. Track authentication setup duration and failure category separately from feature assertions. A sudden increase across every shard may indicate identity-provider throttling, a consent-page change, secret rotation, or the wrong target environment. One clear setup failure is more actionable than hundreds of feature tests that all report a login redirect.

Also test the operating procedure before relying on it. Rotate the automation password in a safe environment, revoke one session, and confirm that the next run regenerates state without exposing secrets. Simulate a missing file and verify the setup dependency creates it before consumers start. If state is transferred between stages, intentionally omit the artifact once and ensure the consumer fails with a precise prerequisite message rather than starting anonymous. These exercises validate the recovery path that teams often discover only during an incident. Document who can revoke the test accounts, where authentication failures are escalated, and how a compromised state artifact is removed from every retained build. Operational ownership completes the reuse design.

Interview Questions and Answers

Q: Show the minimum code needed to reuse authentication state.

After successful login, call page.context().storageState({ path: authFile }). In a dependent browser project, set use.storageState to authFile. Playwright then creates fresh contexts initialized from that snapshot.

Q: Why save state after an authenticated UI assertion?

Cookies may be set during redirects and the application may initialize origin storage after navigation. A stable authenticated element proves the session reached a usable state. Saving earlier can create a valid JSON file that still redirects dependent tests to login.

Q: How do you choose between shared and worker state?

I ask whether concurrent tests can safely act as the same backend user. Read-only tests may share one snapshot. Tests that mutate user-owned state use one account and state file per worker or per test.

Q: How would you model an admin approving a user's action?

I create two BrowserContexts with separate admin and user state files, then expose their pages through fixtures. Each client remains isolated while both interact with one intentional server record. Teardown closes both contexts.

Q: Why is API authentication useful?

It removes repetitive UI and identity-provider dependencies from feature tests while establishing the same cookies. It is appropriate only when the API flow is supported and produces a browser-usable session. Dedicated UI tests still cover the sign-in journey.

Q: What changes when authentication uses IndexedDB?

The setup context saves state with indexedDB: true. I verify that IndexedDB is actually required and protect the broader snapshot. sessionStorage remains separate and needs an init-script pattern.

Q: How should CI react to expired state?

The authentication setup should fail clearly and regenerate state at the next defined run. Feature tests should not silently repair it. CI generally creates disposable fresh state per job and keeps any transfer artifact tightly restricted.

Common Mistakes

  • Saving before login redirects and application initialization complete.
  • Committing state files or uploading them with long, broad artifact retention.
  • Using one admin state for every role and masking authorization gaps.
  • Sharing one backend account across mutating parallel workers.
  • Assuming API login produced a browser-usable cookie without checking a protected page.
  • Enabling IndexedDB capture without knowing whether the application needs it.
  • Expecting sessionStorage to be included in storageState.
  • Switching identity in a live context while old pages and application memory remain.
  • Letting logout or revocation tests invalidate the shared account used by other tests.
  • Silently relogging in feature tests instead of diagnosing expired or wrong-environment state.

Conclusion

Useful Playwright storageState reuse examples make scope explicit. A setup project and shared file fit independent tests, role files preserve authorization intent, actor fixtures model collaboration, and worker-scoped state protects mutating parallel runs. API login, IndexedDB capture, anonymous overrides, and sessionStorage restoration solve narrower cases.

Implement the smallest pattern that matches your application's storage and backend behavior. Then prove it with a protected-route check, an intentional expiry or signed-out scenario, and a CI review that confirms state is never committed, logged, or retained longer than necessary.

Interview Questions and Answers

Describe a basic storageState reuse implementation.

A setup test signs in, waits for a stable authenticated signal, and saves page.context().storageState to a protected file. Browser projects depend on setup and set use.storageState to that path. Feature tests then start in fresh authenticated contexts.

When would you use API login instead of UI login?

I use API login when feature tests need identity but are not validating the sign-in page, and the API produces the same session. It is faster and usually less brittle. I keep dedicated UI tests for the actual authentication journey.

How can an admin and user interact in the same test?

A fixture creates two contexts with different state files and yields an admin page and a user page. Their cookies and origin storage stay separate. The accounts and backend records must also be designed for concurrent interaction.

How do you avoid shared-account collisions in parallel tests?

I allocate one account per worker through an atomic pool, create state in a clean worker-scoped request context, and store it under the worker's output directory. I use parallelIndex as an input to allocation, not as proof of global uniqueness across pipelines.

What should happen when auth state expires?

State should be regenerated at a defined setup boundary. Feature tests should fail with a clear authentication diagnosis rather than silently relogging. CI usually creates fresh state every run, while local use needs a documented refresh command.

Does storageState handle sessionStorage?

No. If authentication depends on sessionStorage, I capture it explicitly and restore it with addInitScript before navigation. The script is restricted to the expected hostname to avoid leaking values to other origins.

How do you protect storageState in CI?

I prefer creating it inside each job, use least-privilege accounts, never commit or print it, and keep artifact retention minimal if transfer is unavoidable. Traces and logs are reviewed for token exposure. Cleanup and revocation are part of incident handling.

Frequently Asked Questions

Can Playwright reuse login state across test files?

Yes. Save state during a setup project and set use.storageState in each dependent project. Every test still receives a fresh browser context initialized from the same snapshot.

How do I create different Playwright storage states for admin and user?

Authenticate separate least-privilege accounts and save each context to a distinct file such as admin.json and user.json. Map projects or custom fixtures to the appropriate file instead of changing one account's role.

Can I reuse storageState with APIRequestContext?

Yes. A request context can save cookies and origin state with request.storageState({ path }) after a supported API login. A browser project can then load that file if the API establishes the same session the web application expects.

How can one Playwright test use two authenticated users?

Create two browser contexts from the browser fixture, each with a different storageState path, and create one page per context. Close both contexts in fixture teardown so resources and sessions remain isolated.

How do I include IndexedDB in a Playwright auth state file?

After completing login in a browser context, call context.storageState({ path, indexedDB: true }). Use this only when required by the application's authentication library.

How do I run a signed-out test when the project is authenticated?

Set test.use({ storageState: { cookies: [], origins: [] } }) at file scope or define a separate anonymous project. Then assert the expected redirect or public experience.

Why does a saved storageState still redirect to login?

The snapshot may have been saved before redirects completed, expired, been revoked, targeted the wrong domain, omitted required IndexedDB or sessionStorage, or come from a different environment. Verify a protected route immediately after state creation and log only nonsecret provenance.

Related Guides