Reusing Authenticated State Across Playwright Tests

Logging in through the user interface at the start of every test is the most expensive habit in end-to-end testing, and the least useful: the login form gets tested sixty times while the journeys you actually care about pay four seconds each for the privilege. Playwright’s storage state solves this properly — authenticate once in a setup project, serialise the cookies and local storage to disk, and start every other spec on an already-signed-in page. This guide is for engineers running Playwright 1.4x against an application with real session handling, whether that is a cookie, a bearer token in local storage, or both. It covers the setup project, multi-role state, expiry, per-worker accounts for parallel runs, and the failure modes that make a cached session worse than no session at all.

Root Cause Analysis

Per-test UI login is slow for an obvious reason and dangerous for a subtler one. The obvious cost is arithmetic: a login flow that takes 3.5 seconds, multiplied across a suite of sixty specs, is three and a half minutes of wall clock spent proving the same form works. On four workers that is still most of a minute, and it grows linearly with every test you add — exactly the dynamic that pushes suites out of the pull request pipeline and into a nightly run nobody reads.

The subtler cost is coupling. When every journey begins by typing into the login form, every journey depends on authentication being healthy, so a change to the sign-in screen turns the whole suite red at once and tells you nothing about which feature broke. Session reuse decouples the two: one setup test owns authentication and fails loudly by itself, while the journeys fail only for their own reasons. That separation is the same principle as keeping setup out of assertions, applied at the level of the whole run, and it is what makes a suite’s failures diagnosable rather than merely alarming.

Run time with per-test login versus one saved session Per-test login spends a login block before every journey, so total time grows with test count. With a saved session there is one login block for the whole run and each journey starts at its own work. Per-test UI login login journey 1 login journey 2 login journey 3 login journey 4 4 logins — 14s of the 30s run is the same form One saved session setup journey 1 journey 2 journey 3 journey 4 1 login — the saving grows with every test added
Per-test login makes authentication a tax on every future test; a saved session makes it a fixed cost.

Reproducible Setup

You need a directory for the state files, a gitignore entry so sessions never reach the repository, and credentials from the environment rather than source.

mkdir -p e2e/.auth
echo "e2e/.auth" >> .gitignore
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      testMatch: /specs\/.*\.spec\.ts/,
      use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/member.json' },
      dependencies: ['setup'],
    },
  ],
});
# .env.e2e — never commit real credentials; CI supplies these as secrets
E2E_MEMBER_EMAIL=member@example.test
E2E_MEMBER_PASSWORD=E2E_ADMIN_EMAIL=admin@example.test
E2E_ADMIN_PASSWORD=

Implementation

Step 1 — Write the setup test. It is an ordinary test that ends by serialising the context. Assert that login actually succeeded before saving, or you will cheerfully persist a logged-out session and spend an hour wondering why.

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

setup('authenticate as member', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.E2E_MEMBER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.E2E_MEMBER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  // The guard that makes a bad save impossible.
  await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
  await page.context().storageState({ path: 'e2e/.auth/member.json' });
});

Step 2 — Add a project per role, not a helper per role. Roles are a run-level concern. One setup test and one project per role keeps role coverage visible in the configuration and in the report, instead of hidden inside a fixture.

// playwright.config.ts — one project per role
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'member',
    testMatch: /specs\/member\/.*\.spec\.ts/,
    use: { storageState: 'e2e/.auth/member.json' },
    dependencies: ['setup'],
  },
  {
    name: 'admin',
    testMatch: /specs\/admin\/.*\.spec\.ts/,
    use: { storageState: 'e2e/.auth/admin.json' },
    dependencies: ['setup'],
  },
],

Step 3 — Skip the form entirely where the API allows it. If your backend can issue a session without a browser, do that: request a token, inject it into the context, and save. This is faster and immune to login-page changes, which matters when the suite’s job is to test something else.

// e2e/auth.setup.ts — API login, then seed the browser context
import { test as setup, request } from '@playwright/test';

setup('authenticate via API', async ({ browser, baseURL }) => {
  const api = await request.newContext({ baseURL });
  const res = await api.post('/api/session', {
    data: { email: process.env.E2E_MEMBER_EMAIL, password: process.env.E2E_MEMBER_PASSWORD },
  });
  const { token } = await res.json();

  const context = await browser.newContext({ baseURL });
  await context.addCookies([
    { name: 'session', value: token, domain: 'localhost', path: '/', httpOnly: true, sameSite: 'Lax' },
  ]);
  await context.storageState({ path: 'e2e/.auth/member.json' });
  await context.close();
});

Step 4 — Handle local-storage tokens as well as cookies. Applications that keep a bearer token in local storage need the origin seeded, which storageState supports directly. Write the state file yourself when there is no form to drive.

// e2e/.auth/member.json — a hand-built state file for a token-based app
{
  "cookies": [],
  "origins": [
    {
      "origin": "http://localhost:3000",
      "localStorage": [{ "name": "access_token", "value": "eyJhbGciOi…" }]
    }
  ]
}

Step 5 — Give each worker its own account when tests mutate the profile. A shared session is safe for read-mostly journeys. As soon as a test changes a display name or a preference, workers start seeing each other’s writes, so scope the account to the worker index.

// e2e/fixtures/worker-auth.ts — one session per worker, created once
import { test as base } from '@playwright/test';

export const test = base.extend<{}, { workerStorageState: string }>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),
  workerStorageState: [
    async ({ browser }, use, workerInfo) => {
      const file = `e2e/.auth/worker-${workerInfo.workerIndex}.json`;
      const context = await browser.newContext();
      // …sign in as member+<index>@example.test, then:
      await context.storageState({ path: file });
      await context.close();
      await use(file);
    },
    { scope: 'worker' },
  ],
});
Choosing the scope of a saved session A decision path: read-only journeys share one run-scoped session; journeys that mutate the profile need a worker-scoped session; journeys that test the login flow itself must use no saved session at all. Does the test mutate the account itself? no yes One run-scoped session setup project + storageState Worker-scoped session one account per worker index Testing login itself? storageState: undefined — start clean
Session scope follows what the test writes: nothing shared, one per worker, or deliberately none.

Step 6 — Keep the login flow itself under test. One spec must still exercise the real form, with the saved session explicitly disabled, otherwise the one journey every user takes is the only one you never check.

// e2e/specs/login.spec.ts
import { test, expect } from '@playwright/test';

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

test('rejects a wrong password and keeps the user on the form', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('member@example.test');
  await page.getByLabel('Password').fill('wrong');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('alert')).toContainText('Incorrect email or password');
});

Verification

The setup project should appear in the report as a test in its own right, and the journeys should never show a login step. Run the suite and read the list of executed tests.

npx playwright test --reporter=list
# [setup] › auth.setup.ts:5:1 › authenticate as member (2.1s)
# [member] › specs/basket.spec.ts:7:1 › adds an item (1.4s)
# [member] › specs/profile.spec.ts:9:1 › shows saved addresses (1.1s)

Then prove the session is genuinely applied rather than accidentally regenerated, with a guard spec that fails fast and unambiguously if state loading ever breaks.

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

test('starts authenticated with no redirect to login', async ({ page }) => {
  const response = await page.goto('/dashboard');
  expect(response?.status()).toBe(200);
  await expect(page).not.toHaveURL(/\/login/);
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
What the state file must contain for the session to apply The saved file holds a session cookie bound to one origin plus any local storage entries; a mismatch in origin, an empty cookie array, or an expired value each produce a redirect back to the login page. member.json cookies: session=… origin: localhost:3000 localStorage: token /dashboard — 200 all three match the app origin /login — redirected wrong origin, empty, or expired applied ignored
A session applies only when the cookie, the origin and the token all match what the application expects.

Finally, confirm the saving is worth what it costs by timing the suite both ways once. A single measurement is enough to settle the argument, and it is the kind of number worth recording in test observability and reporting so the improvement does not quietly regress.

Troubleshooting

Symptom: every spec redirects to the login page. Diagnosis: the state file is missing, empty, or scoped to the wrong origin — a session saved against localhost:3000 does not apply to 127.0.0.1:3000, because cookies are origin-bound. Fix: assert in setup before saving, use one canonical base URL everywhere, and print readFileSync('e2e/.auth/member.json') once in CI to confirm the cookie is actually there.

Symptom: tests pass locally and fail in CI after a few minutes. Diagnosis: a cached state file from an earlier run holds an expired token, because the auth directory was restored from a CI cache. Fix: never cache e2e/.auth; regenerate it on every run. The setup project costs two seconds and removes the entire class of failure.

Symptom: two tests fight over the same profile. Diagnosis: a shared run-scoped session is being mutated — one test renames the account while another asserts the old name. Fix: move to worker-scoped sessions as in Step 5, or make the mutating test create and delete its own account, following the isolation rules in isolating end-to-end tests with per-worker data.

FAQ

Is reusing a session cheating, since real users log in?

Real users log in once and then use the product for hours, so a reused session is closer to real usage than logging in before every action. The login flow still deserves its own test, and Step 6 keeps one. What you are removing is sixty redundant executions of a flow you have already verified, which is the same reasoning behind setting up data through an API and asserting through the interface.

How long can a storage state file safely live?

Only as long as one run. Treat it as a build artifact with a lifetime of minutes: generated by the setup project at the start, discarded at the end, never cached between runs. If your tokens are short-lived enough that a long suite outlives them, either shorten the run with sharding or issue test-only tokens with a lifetime comfortably longer than the suite.

Can I use one session for several browsers or devices?

Yes — cookies and local storage are origin-scoped, not browser-scoped, so the same file works for Chromium, Firefox and WebKit projects as long as the origin matches. What does not transfer is anything the application stores per device, such as a “trust this device” flag. If your login writes such a marker, generate a state file per project rather than sharing one.

What about applications that require a one-time code or second factor?

Use a test-only path rather than automating the second factor through the browser. Common approaches are an API login that skips the challenge for accounts flagged as test users, a fixed code in non-production environments, or reading the code from a mail-catcher service in setup. All three keep the challenge itself testable in one dedicated spec while the rest of the suite starts signed in.