SoftwareTestPilot
Automation TestingPublished: 16 min read

Playwright Authentication with Storage State: Setup, Reuse and Expiry

How Playwright storage state really works: what it saves, setup projects, reusing sessions, handling expiry, keeping session files secret and isolating accounts across parallel workers.

SoftwareTestPilot
QA Editorial Team
Share:XLinkedInWhatsApp
Playwright authentication with saved storage state shared across test projects
Playwright authentication with saved storage state shared across test projects

Last updated 2026-10-16 · 16 min read · By SoftwareTestPilot

Logging in through the user interface at the start of every test is the slowest and most fragile step in many Playwright suites. Storage state removes it: you sign in once, save what the browser remembers about that session, and hand the saved file to every test that needs a signed-in user. It is one of the most useful features in Playwright, and also one of the most misunderstood. Teams copy the standard example, it works on day one, and weeks later they hit tests that randomly sign out, accounts that fight each other in parallel runs, or a session file that ended up in a public build artifact.

This guide explains how storage state behaves, not just how to call it. You will see what the file actually contains, how to produce it reliably, the different ways to reuse it, what happens when the session expires, how to protect it, and how to keep parallel tests from interfering. Just as important, it explains where saved state cannot help, so you can choose a different approach instead of fighting the tool.

Examples use TypeScript and Playwright Test, and a fictional shop at https://shop.example.test. Selectors and endpoints are placeholders; adapt them to your application.

What storage state actually saves

Calling storageState() on a browser context, or on an API request context, returns a JSON snapshot of the session data the browser holds. Passing a path also writes it to disk. The file has two main parts:

  • cookies: every cookie in the context, including HttpOnly and Secure cookies that page JavaScript cannot read. Each entry keeps its domain, path, expiry and flags.
  • origins: for each origin, the localStorage key-value pairs. Since Playwright 1.51 you can pass { indexedDB: true } to also capture IndexedDB, which some authentication libraries use to keep tokens.
await page.context().storageState({
  path: 'playwright/.auth/user.json',
  indexedDB: true, // only needed if your app keeps auth data in IndexedDB
});

When a new context is created with storageState, Playwright adds the cookies and fills localStorage (and IndexedDB, if present) before your first navigation. To the application, the browser looks exactly like one that signed in earlier.

What it does not save

DataSaved?What it means for you
Cookies (including HttpOnly)YesCookie-based sessions reuse cleanly.
localStorageYesToken-in-localStorage apps reuse cleanly.
IndexedDBOnly with indexedDB: trueTurn it on if your auth library stores tokens there.
sessionStorageNoNeeds a manual workaround, shown later.
Tokens held only in JavaScript memoryNoThe app must re-obtain them, usually through a refresh call.
Service worker caches, open tabs, form stateNoNot part of a session snapshot.

The first question to ask before adopting storage state is therefore simple: where does your application keep the proof that a user is signed in? Open the browser developer tools after a manual login and look at Application → Cookies, Local Storage, Session Storage and IndexedDB. If the session lives in cookies or localStorage, storage state will work with no extra effort.

Producing the file in a setup project

The recommended way to create the file is a setup project: a normal Playwright project whose only job is to sign in and save state. Other projects declare it as a dependency, so Playwright runs it first and only starts them if it passes. Because it is a real project, the login appears in the HTML report and can produce a trace when it fails, which makes broken logins easy to diagnose.

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

const userFile = 'playwright/.auth/user.json';

setup('sign in as standard user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.SHOP_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.SHOP_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for proof the session is fully established before saving.
  await page.waitForURL('/account');
  await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();

  await page.context().storageState({ path: userFile });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'https://shop.example.test' },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});

Two details matter more than they look. First, wait for a reliable signal before saving. Many apps redirect, then set or rotate cookies in a follow-up request. If you save immediately after clicking Sign in, you may capture a half-finished session. Waiting for the final URL and a signed-in element is safer than a fixed delay. Second, the setup project runs on every test run, so the file is regenerated each time you run npx playwright test. That keeps sessions fresh in CI. The exception is the --no-deps flag, which skips dependencies and reuses whatever file is already on disk.

Signing in through the API instead

If your application has a login endpoint that sets session cookies, the setup step can skip the browser entirely. The API request context keeps cookies just like a browser context and can save them the same way:

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

setup('sign in via API', async ({ request }) => {
  const res = await request.post('/api/auth/login', {
    data: {
      email: process.env.SHOP_USER_EMAIL,
      password: process.env.SHOP_USER_PASSWORD,
    },
  });
  expect(res.ok()).toBeTruthy();
  await request.storageState({ path: 'playwright/.auth/user.json' });
});

This only captures cookies. If your app stores its token in localStorage after reading a JSON response, an API login will not populate localStorage and the browser will look signed out. In that case use the UI login, or open a page and write the token yourself before saving.

Reusing the session: project, file or test level

Once the file exists, you can apply it at different levels. The rule is the same at each: storageState is a context option, so it applies when a new context is created.

  • Project level (the config above): every test in that project starts signed in. Best for suites where nearly everything needs one user type.
  • File or describe level with test.use: override the project default for a group of tests.
  • Inside a test: create extra contexts with different state when one test needs more than one user.
// tests/public-pages.spec.ts — run these signed out
import { test, expect } from '@playwright/test';

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

test('home page shows sign in link', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
});

The empty object is the documented way to opt a group of tests out of the project's saved session. It is cleaner than maintaining a separate project just for signed-out pages.

Several roles

Real applications have more than one kind of user. Generate one file per role in the setup project, then pick the role per file or per test. A full treatment of role design, including admin and customer interacting in the same test, is in the multi-user section of our Playwright custom fixtures example. The short version:

// tests/auth.setup.ts — one session per role
import { test as setup } from '@playwright/test';

const roles = [
  { name: 'admin', email: process.env.SHOP_ADMIN_EMAIL!, password: process.env.SHOP_ADMIN_PASSWORD! },
  { name: 'customer', email: process.env.SHOP_USER_EMAIL!, password: process.env.SHOP_USER_PASSWORD! },
];

for (const role of roles) {
  setup(`sign in as ${role.name}`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(role.email);
    await page.getByLabel('Password').fill(role.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/account');
    await page.context().storageState({ path: `playwright/.auth/${role.name}.json` });
  });
}
// tests/admin-products.spec.ts
import { test, expect } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/admin.json' });

test('admin can hide a product', async ({ page }) => {
  await page.goto('/admin/products');
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

When one test needs two users at once, for example an admin who approves a review that a customer then sees, create two contexts from the browser fixture:

test('customer sees an approved review', async ({ browser }) => {
  const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const customerContext = await browser.newContext({ storageState: 'playwright/.auth/customer.json' });
  try {
    const admin = await adminContext.newPage();
    const customer = await customerContext.newPage();
    // ...admin approves, customer reloads and checks
  } finally {
    await adminContext.close();
    await customerContext.close();
  }
});

Contexts you create yourself are not closed automatically, so close them in finally, or better, wrap them in fixtures so teardown is guaranteed. If you are deciding where that setup belongs, our comparison of Playwright fixtures vs hooks explains why fixtures handle this more safely than beforeEach.

Session expiry: what breaks and how to handle it

A storage state file is a snapshot. Playwright does not refresh it, check it or know whether the server still accepts it. Three things make a saved session stop working:

  1. Time-based expiry. The cookie or token has an expiry date, or the server ends idle sessions after a timeout.
  2. Server-side revocation. A password change, a sign-out in another test, a deployment that rotates signing keys, or a policy that allows only one active session per user.
  3. Refresh-token rotation. Many token systems issue a new refresh token on each use and invalidate the old one. The first test that refreshes succeeds; every other context still holding the old copy is now signed out.

Because the setup project regenerates the file at the start of each run, the first cause mostly matters for long runs and for local work with --no-deps. If a full CI run is longer than your session lifetime, ask for a longer-lived test session in the test environment, or split the run. Do not try to extend sessions by editing cookie expiry dates in the JSON; the server decides whether a session is valid, not the cookie date.

The second and third causes are design problems, not timing problems. Watch for these symptoms: tests pass alone and fail together, failures cluster after a particular test runs, or a test that signs out breaks unrelated tests. The fixes are:

  • Never sign out with a shared account. Test sign-out with a dedicated account that nothing else uses, or with a session created inside that test.
  • Give parallel workers separate accounts when the app limits concurrent sessions or rotates refresh tokens (see the next section).
  • Make failures obvious. A cheap check at the start of signed-in tests, such as asserting the account menu is visible, turns a confusing locator timeout into a clear "session not valid" failure.

Optional: reusing a file locally while it is fresh

When developing locally you may want to skip the login while the file is still young. A small guard in the setup test can do that. Keep the window shorter than the real session lifetime, and leave CI to regenerate every run:

import fs from 'node:fs';

setup('sign in as standard user', async ({ page }) => {
  const maxAgeMs = 30 * 60 * 1000;
  if (!process.env.CI && fs.existsSync(userFile) &&
      Date.now() - fs.statSync(userFile).mtimeMs < maxAgeMs) {
    return; // recent file, reuse it locally
  }
  // ...normal login and save
});

This is a convenience, not a guarantee. A file that is ten minutes old can still be revoked by the server.

Protecting session files and credentials

Anyone who has a valid storage state file can act as that user until the session ends. Treat it like a password.

  • Ignore it in Git. Add playwright/.auth to .gitignore before the first run. It is easy to commit by accident because the file appears right after your first successful test.
  • Keep credentials out of code. Read emails and passwords from environment variables, loaded from a local .env file that is also ignored, and from your CI system's secret store in pipelines.
  • Use test accounts only. Never point the suite at real customer accounts or production admin users. Give test accounts the minimum permissions their tests need.
  • Be careful with artifacts. Traces record network requests, including cookie and authorization headers, and some reports attach them. If you upload traces or the HTML report from CI, keep them in private storage with a short retention period. The same applies if you ever upload the .auth folder as an artifact; usually you should not.
  • Do not print state. Avoid logging the JSON or individual cookies while debugging; CI logs are often visible to more people than the test environment.

If a file does leak, sign the test account out of all sessions or change its password, which revokes the copied session on most systems.

Parallel tests: one account or one per worker?

Each test gets a fresh browser context, so browser-side state is never shared between tests, even when they load the same file. What can be shared is the server-side state of the account. If two workers sign in as the same user and one empties the cart while the other adds to it, they will interfere no matter how isolated the browsers are.

A single shared account is fine when tests only read data or when each test creates its own records (a new order, a new project) and touches nothing global. Use one account per worker when tests change account-wide things such as profile details, settings, a single cart or notification preferences, or when the application limits concurrent sessions.

Playwright's documentation shows a worker-scoped fixture for this. It uses test.info().parallelIndex, a number between zero and the worker count minus one, to choose an account and save one file per worker:

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

export const test = base.extend<{}, { workerStorageState: string }>({
  // Every test uses the per-worker file.
  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;
    }

    // Accounts qa-worker-0, qa-worker-1, ... prepared in the test environment.
    const page = await browser.newPage({ storageState: undefined });
    await page.goto('/login');
    await page.getByLabel('Email').fill(`qa-worker-${id}@example.test`);
    await page.getByLabel('Password').fill(process.env.SHOP_WORKER_PASSWORD!);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/account');
    await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
    await page.context().storageState({ path: fileName });
    await page.close();
    await use(fileName);
  }, { scope: 'worker' }],
});

Spec files import test from this file instead of @playwright/test. You need at least as many test accounts as your maximum worker count, and the accounts must exist before the run. With sharding across several machines, each machine has its own workers numbered from zero, so two shards can pick the same account at the same time. Include the shard number in the account name (for example from an environment variable your pipeline sets) or give each shard its own account pool.

A good test of isolation is to run the suite with several workers and --repeat-each=3. Hidden sharing usually shows up as failures that move around between runs.

Where storage state cannot help

Storage state is not a universal login skip. It works when the session is something a browser can carry in cookies or storage and the server accepts it from a new browser. These designs need a different approach:

  • sessionStorage-based sessions. Not saved. Playwright's docs show a workaround: read sessionStorage after login, save it yourself, and restore it with context.addInitScript before navigation. It works, but it is your code to maintain.
  • Tokens kept only in memory. Single-page apps that hold the access token in a JavaScript variable will look signed out on reload unless a refresh call (usually cookie-based) can recover it. If that refresh cookie is saved, reuse works; if not, it cannot.
  • Multi-factor authentication. Saved state can carry a session that has already passed MFA, but something still has to complete MFA at login. Common choices are a test environment where MFA is disabled for test accounts, a time-based code generated from a test secret, or a test-only login endpoint. Choose with your security team.
  • Device or IP binding. Some systems tie sessions to a fingerprint, IP address or client certificate. A file created on one machine may be rejected on another, or in a different browser engine. Generate the file in the same environment that uses it.
  • Third-party identity pages with bot protection. CAPTCHA and risk checks on external login pages are designed to stop automation. Use a test identity provider tenant, a bypass agreed for test environments, or API-based token creation instead of automating the external page.
  • Single active session policies. If signing in again ends the previous session, a shared account across workers or shards will keep signing itself out. Use per-worker accounts.

When none of the standard approaches fit, the most reliable option is usually a small test-only endpoint in your non-production environments that creates a session for a test user directly. That is a product decision, not a Playwright feature, and it must never be deployed to production.

Debugging a session that does not work

When tests start signed out despite a saved file, work through these checks in order:

  1. Open the JSON. Are the session cookies present? Is the cookie domain the one your tests visit? A file saved on shop.example.test will not sign you in on staging.shop.example.test unless the cookie domain covers both.
  2. Check the base URL. Local storage is stored per origin, including scheme and port. http://localhost:3000 and http://127.0.0.1:3000 are different origins.
  3. Check timing. Did you wait for the final redirect and a signed-in element before saving?
  4. Check where the app keeps auth. If the token is in sessionStorage or IndexedDB, the default call misses it.
  5. Look at the setup trace. Run with --trace on and open the setup project's trace to see the network calls and cookies set during login.
  6. Run one test alone, then the suite. If one test passes but the suite fails, suspect revocation or a shared account, not the file.

Checklist

  • You know where your app keeps its session, and storage state covers it.
  • A setup project creates the file, waits for a signed-in signal, and browser projects depend on it.
  • Signed-out tests opt out with an empty storage state.
  • Each role has its own file; tests needing two roles use separate contexts that are always closed.
  • playwright/.auth and .env are ignored in Git; credentials come from environment variables or CI secrets.
  • Traces and reports with cookies are stored privately with short retention.
  • No test signs out a shared account.
  • Tests that change account-wide data use one account per worker, and shards do not collide.
  • The suite passes with several workers and --repeat-each=3.

Summary

Storage state turns login into a once-per-run step: a setup project signs in, saves cookies and local storage, and every dependent project starts signed in. It saves less than many people assume, nothing in sessionStorage or memory, and it never refreshes itself, so expiry and revocation are your responsibility. Keep the files and the traces that contain them private, and when tests change account-wide data, give each worker its own account. For sessions that depend on MFA, device binding or external login pages, plan a test-environment approach with your team instead of expecting saved state to cover it.

To wrap these sessions in reusable, typed fixtures, continue with the Playwright custom fixtures example, which also covers multi-user tests. For the wider project structure, read Playwright framework setup with TypeScript and, for API-driven setup, the Playwright API testing framework guide.

Learn Playwright authentication patterns in the Playwright course, where you build setup projects, role fixtures and API login inside a full framework.

Key takeaways

  1. 1storageState saves cookies and localStorage (and IndexedDB when you ask for it); it does not save sessionStorage or in-memory tokens.
  2. 2Produce the file in a setup project and make browser projects depend on it, so login runs once per test run.
  3. 3Playwright never refreshes a saved session. If the server expires or revokes it, tests fail until the file is regenerated.
  4. 4Session files are live credentials: ignore them in Git and treat traces and reports as sensitive too.
  5. 5If tests change server-side state that affects other tests, give each parallel worker its own account.

Common mistakes to avoid

  • Expecting storage state to restore sessionStorage or a token kept only in JavaScript memory.
  • Saving state before the app has finished writing its cookies, producing a file that is not actually signed in.
  • Committing playwright/.auth/ to the repository or uploading traces with live cookies to a public artifact.
  • Sharing one account across all workers while tests change that account's profile, cart or settings.
  • Running with --no-deps locally and wondering why an old, expired session file is used.

References

  1. 1
    Playwright: Authentication— playwright.dev
  2. 2
  3. 3
  4. 4
  5. 5
    Playwright: Fixtures— playwright.dev