Playwright architecture, selector reliability, and advanced interaction patterns.

Authentication & Session State

Almost every interesting page in a real application sits behind a login, and the naive answer — drive the login form at the top of every test — turns a 40-second suite into a 12-minute one and makes the identity provider the single most common cause of red builds. Playwright's answer is to treat an authenticated session as a serialisable artifact: log in once, write the cookies and origin storage to a JSON file, and hand that file to every browser context that needs it. This guide covers the whole lifecycle of that artifact — how a session is actually represented, how to capture it in a dedicated setup project, how to inject it at project, worker, and test scope, how to keep several roles alive simultaneously without them contaminating each other, and how to detect and recover when a saved session goes stale. It sits beneath Playwright Setup & Core Architecture, which frames how the Browser, BrowserContext, and Page objects relate to one another.

Setup project feeding authenticated browser projects A setup project logs in once and writes a storageState file, which three dependent browser projects then load before running their tests. setup project auth.setup.ts logs in once storageState file playwright/.auth/user.json cookies + origins dependencies: ['setup'] chromium project firefox project webkit project
One login produces one JSON artifact that every dependent project reads before its first test starts, so the identity provider is touched once per run instead of once per test.

What a session actually is

A logged-in session is not a single token. It is a set of cookies scoped to specific domains and paths, plus whatever the application chose to write into localStorage for each origin it touched, plus — sometimes — data the framework hid in sessionStorage or IndexedDB. Playwright's context.storageState() serialises exactly two of those: the context's full cookie jar, including HttpOnly cookies that JavaScript can never read, and the localStorage key/value pairs for every origin the context has visited. The result is a plain JSON document with two top-level arrays, cookies and origins, and nothing else.

That boundary is the source of most confusion. If your application keeps its access token in an HttpOnly cookie, storageState captures everything you need and reuse works on the first attempt. If it keeps a JWT in localStorage, it still works, provided the page had already navigated to that origin when you saved. If it stores the session in sessionStorage — which is per-tab and dies with the tab — or in IndexedDB, which is what Firebase Auth and several offline-first frameworks do, storageState will happily write a file that restores nothing, and your tests will land on the login screen with no error message to explain why.

What the storageState file does and does not persist A two-column comparison listing the cookie and localStorage data captured by storageState against the session storage, IndexedDB and in-memory state it omits. context.storageState() output Captured cookies, incl. HttpOnly domain, path, expires sameSite and secure localStorage per origin origins array of visits Not captured sessionStorage IndexedDB stores service worker caches in-memory redux state HTTP basic credentials
Anything on the right must be rehydrated by hand with an init script, because loading a storageState file will silently leave it empty.

There is a related asymmetry worth naming. Reading state is a browser-level operation — context.storageState() asks the engine for its cookie jar — whereas restoring it is an option consumed at context creation time. You cannot inject cookies into a context that already exists by passing storageState after the fact; the option is read once, when newContext() builds the partition. That is why every technique below is really a question about when a context is created and what it is created with, and why mutating an already-running context requires context.addCookies() or context.addInitScript() instead.

The second thing to internalise is that a saved session is bound to an origin. Cookies carry a domain and a path, and the browser only replays them for matching requests. A state file captured against http://localhost:3000 is inert when the suite runs against https://staging.example.com, and the failure looks identical to an expired token. Keep one state file per environment, key it by the baseURL you captured it under, and never commit it to version control — it is a live credential.

Prerequisites

This guide assumes a Playwright project created with npm init playwright@latest, a playwright.config.ts you are willing to edit, and a baseURL set in the use block so relative navigations resolve. You should be comfortable with Browser Contexts & Isolation, because everything below is ultimately a statement about which context receives which cookie jar, and with fixtures as described in Playwright Config & Fixtures, since worker-scoped authentication is implemented as a fixture.

You also need a test account whose credentials arrive through environment variables rather than source. Create a directory playwright/.auth/ and add it to .gitignore on the first commit; a state file leaked into a public repository is a session hijack waiting to happen. Finally, decide up front whether your application's session lives in cookies or in localStorage, because that single fact determines whether the API-first shortcut later in this guide is available to you.

Capturing the session in a setup project

A setup project is an ordinary Playwright project whose testMatch selects files ending in .setup.ts, and which other projects declare as a dependency. Playwright runs it to completion before any dependent project starts, and if it fails, the dependents are skipped rather than run against a missing file — a much clearer signal than a hundred tests timing out on a login form.

The setup file itself is a normal test. That is the whole point of preferring it over globalSetup: it gets fixtures, retries, tracing, screenshots on failure, and a line in the HTML report, so when authentication breaks you get a trace of the broken login instead of an opaque stack trace from a Node script.

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

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

setup('authenticate as the standard user', async ({ page }) => {
  await page.goto('/login');

  // Role-based queries survive the marketing team restyling the login page.
  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();

  // Never save state on a hopeful click. Wait for a signal that only exists
  // once the server has actually issued the session cookie.
  await page.waitForURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // storageState() reads the cookie jar from the context, so HttpOnly cookies
  // are included even though page.evaluate() could never see them.
  await page.context().storageState({ path: AUTH_FILE });
});

Wiring it up is two edits to the config: a project that matches the setup file, and a dependencies array plus a storageState on every project that needs to be logged in.

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000' },
  projects: [
    // Runs first. Produces playwright/.auth/user.json.
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Every context this project creates starts pre-authenticated.
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});

The mechanics of the setup project — naming, ordering, teardown projects, and what happens when you have several of them — are worked through in Setting Up an Auth Setup Project, which walks the config from an empty file to a multi-role dependency graph.

Injecting state at project, test, and context scope

storageState is accepted at three levels, and knowing which one to reach for keeps the suite readable. At project level, as above, every test in the project is authenticated. Inside a spec file, test.use() overrides that for a single file — most often to run without authentication, which you express with an empty state object rather than by deleting the option.

At context level, browser.newContext({ storageState }) gives you an authenticated context on demand, which is what you want when a single test needs two identities in the same browser: an admin approving a request and a member seeing it approved.

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

// This file runs signed out even though the project sets a storageState.
test.describe('anonymous visitors', () => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test('the marketing page invites you to sign up', async ({ page }) => {
    await page.goto('/');
    await expect(page.getByRole('link', { name: 'Create account' })).toBeVisible();
  });
});

test('an admin approval is visible to the requesting member', async ({ browser }) => {
  // Two contexts, two cookie jars, one browser process — no cross-talk.
  const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const memberContext = await browser.newContext({ storageState: 'playwright/.auth/member.json' });

  const adminPage = await adminContext.newPage();
  const memberPage = await memberContext.newPage();

  await adminPage.goto('/requests');
  await adminPage.getByRole('row', { name: 'Budget increase' })
    .getByRole('button', { name: 'Approve' }).click();

  await memberPage.goto('/requests/mine');
  await expect(memberPage.getByText('Approved')).toBeVisible();

  // Contexts are cheap but not free; close them so the worker does not grow.
  await adminContext.close();
  await memberContext.close();
});

Note the empty-state literal { cookies: [], origins: [] }. Passing undefined inherits the project value, so the logged-out test would silently run logged in and pass for the wrong reason. Role queries like getByRole() are doing real work here too — they are the most refactor-resistant way to assert an authenticated view, as covered in getByRole & Accessibility Selectors. The file format itself, including how to hand-edit a state file and how to merge two of them, is the subject of Reusing Login State with storageState.

Skipping the UI with an API login

Driving a login form is the slowest and least stable way to obtain a cookie. If your application exposes a JSON login endpoint, authenticate through request instead: no page load, no rendering, no selector that can break, and typically 100–200 ms instead of three seconds. APIRequestContext maintains its own cookie jar and exposes the same storageState() method, so the artifact is interchangeable with one produced through the UI.

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

setup('authenticate over HTTP', async ({ playwright }) => {
  // A standalone request context, independent of any browser.
  const api = await playwright.request.newContext({ baseURL: process.env.BASE_URL });

  const response = await api.post('/api/session', {
    data: { email: process.env.E2E_USER, password: process.env.E2E_PASSWORD },
  });
  // Fail loudly here rather than 200 selector timeouts later.
  expect(response.ok(), `login returned ${response.status()}`).toBeTruthy();

  // Set-Cookie headers landed in this context's jar; serialise them.
  await api.storageState({ path: 'playwright/.auth/user.json' });
  await api.dispose();
});

This only works when the session is cookie-borne. If the API returns a bearer token that the front end stashes in localStorage, the cookie jar will be empty and the resulting file useless. In that case, fetch the token over HTTP and write it into storage before the app boots, using addInitScript so the value is present at the first line of application JavaScript rather than after a race:

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

test('injects a bearer token before the app boots', async ({ browser, playwright }) => {
  const api = await playwright.request.newContext({ baseURL: process.env.BASE_URL });
  const body = await (await api.post('/api/token', {
    data: { email: process.env.E2E_USER, password: process.env.E2E_PASSWORD },
  })).json();

  const context = await browser.newContext();
  // Runs on every navigation in this context, before any page script.
  await context.addInitScript((token: string) => {
    window.localStorage.setItem('access_token', token);
  }, body.access_token as string);

  const page = await context.newPage();
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await context.close();
});

There is a design trade to weigh before adopting the API route wholesale. A UI login exercises the real form, the real validation, and the real redirect, so if you never drive it you can ship a broken sign-in page with a fully green suite. The pragmatic split is to keep exactly one test that logs in through the interface — asserting the error state for bad credentials as well as the happy path — and to obtain every other session over HTTP. That single test is your regression guard for the login flow itself; the API shortcut is a performance optimisation for the several hundred tests that merely need to be somebody.

The same technique rehydrates sessionStorage and seeds an IndexedDB record, which is the only way to reuse a Firebase-style session. When the token comes from a third party rather than your own backend — Auth0, Okta, Entra ID, Google — the redirect chain and the consent screen add failure modes of their own; Handling OAuth and SSO Redirects in Tests covers the redirect waits, the TOTP dance, and when to stub the provider outright with network interception.

One session per worker, one role per project

A single shared state file is fine until two tests mutate the same account concurrently. The moment one test archives a document that another expects to see in the list, fullyParallel turns your suite into a lottery. There are two clean answers, and they compose.

The first is a project per role. Declare a setup file per persona, give each its own state file, and have each role's project depend on the matching setup project while restricting testMatch to that role's specs. Roles then never touch each other's data because they are literally different accounts.

The second is a worker-scoped fixture that mints a fresh account per worker process. test.info().parallelIndex is stable for the lifetime of a worker and bounded by the worker count, so it makes an ideal suffix for both the account name and the state file path. The fixture logs in once per worker, not once per test, so the cost amortises across everything that worker runs.

Per-role state files bound to parallel workers Three role-specific storage state files each feed a different parallel worker process, so concurrent specs never share an account. One state file per role, one role per worker admin.json full permissions editor.json write scope only viewer.json read scope only storageState storageState storageState worker 0 admin specs, own cookie jar worker 1 editor specs, own cookie jar worker 2 viewer specs, own cookie jar
Because each lane owns a distinct account and a distinct state file, two workers can mutate the same feature concurrently without either one observing the other's writes.
// tests/fixtures/roles.ts
import { test as base, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';

type WorkerFixtures = { workerStorageState: string };

export const test = base.extend<{}, WorkerFixtures>({
  // Worker scope: runs once per worker process, not once per test.
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [async ({ browser }, use) => {
    // parallelIndex is stable for this worker's whole lifetime.
    const id = test.info().parallelIndex;
    const file = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);

    // Reuse an existing file so retries do not re-authenticate needlessly.
    if (fs.existsSync(file)) { await use(file); return; }

    const context = await browser.newContext({ storageState: undefined });
    const page = await context.newPage();
    await page.goto('/login');
    await page.getByLabel('Email').fill(`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 context.storageState({ path: file });
    await context.close();
    await use(file);
  }, { scope: 'worker' }],
});

export { expect };

Overriding the built-in storageState option from a worker fixture is what makes this transparent: specs import test from this module and receive an authenticated page with no further ceremony. The role matrix — how many accounts to provision, how to keep their data disjoint, and how to shard them — is expanded in Testing Multiple User Roles in Parallel, and the general pattern of paying an expensive cost once per worker is covered in Setting Up Global Fixtures for Parallel Tests.

Keeping the session fresh

A saved state file is a snapshot of a credential with an expiry. Access tokens commonly live 15 minutes to an hour; a suite that takes 25 minutes and captured its session at minute zero will start failing somewhere in the second half, and the failures will be scattered across many specs rather than concentrated in one, which is exactly the profile engineers misdiagnose as flakiness. Treat expiry as a first-class concern.

Each cookie in the file carries an expires field expressed as Unix seconds, with -1 meaning a session cookie that dies with the browser. Reading that value gives you a cheap staleness check you can run before deciding whether to re-authenticate.

// tests/fixtures/session-freshness.ts
import fs from 'node:fs';

type SavedState = {
  cookies: Array<{ name: string; expires: number }>;
};

/** True when the named cookie is missing, or expires within the safety margin. */
export function sessionIsStale(file: string, cookieName: string, marginSeconds = 600): boolean {
  if (!fs.existsSync(file)) return true;

  const state = JSON.parse(fs.readFileSync(file, 'utf8')) as SavedState;
  const cookie = state.cookies.find((c) => c.name === cookieName);
  if (!cookie) return true;

  // -1 marks a session cookie: it never survives a fresh browser anyway.
  if (cookie.expires === -1) return true;

  const nowSeconds = Date.now() / 1000;
  // Re-auth early so a long-running spec cannot expire mid-flight.
  return cookie.expires - nowSeconds < marginSeconds;
}

Call that guard at the top of the setup project and skip the login when the file is still good; on a developer's laptop this turns the second and subsequent runs into instant starts. The margin matters — expiring exactly at the boundary means a test that begins at second 899 of a 900-second token fails halfway through. Ten minutes of headroom costs nothing and removes the entire class of failure.

Refresh tokens complicate the picture in a useful way. If your application silently renews its access token in the background, a restored session may repair itself on the first navigation, which makes short access-token lifetimes harmless. It also makes the state file mildly deceptive: the access cookie in the file may be long dead while the refresh cookie beside it is still valid, so a naive expiry check on the wrong cookie name reports staleness that does not exist. Check the longest-lived cookie the application actually needs, not the first one in the array.

There is a second, subtler decay: the application's own session invalidation. Logging in again from another machine, rotating a password, or a backend deploy that flushes the session store all invalidate a file that still looks fresh by its expires field. The defence is a cheap liveness probe rather than a longer margin — one authenticated GET /api/me through request that asserts a 200 before the suite commits to the file.

Failure modes and debugging

Authentication failures are unusually hard to read because the symptom always looks the same: a locator times out on a page you did not expect to be on. Work the diagnosis in a fixed order rather than guessing.

Decision path for a test that lands on the login page A four-step decision path checking whether the state file exists, whether its cookie expired, whether the token lives in localStorage, and finally whether the origin matches. test lands on /login state file present? no setup never ran cookie expired? yes re-authenticate origins array empty? yes token not in cookies compare cookie domain to baseURL
Four checks, in order, resolve almost every authentication failure; only after all four do you need to look at the application's own session store.

Error: ENOENT: no such file or directory, open 'playwright/.auth/user.json'. The project referenced a state file that was never produced. Either the dependencies array is missing, the setup project's testMatch does not match the filename, or the setup test threw before reaching storageState(). Run npx playwright test --project=setup alone to see the real error.

Tests are skipped with no failures reported. A dependent project is skipped wholesale when its dependency fails. The report shows the setup test red and everything downstream grey. Read the setup failure first; the downstream count is noise.

browserContext.storageState: Target page, context or browser has been closed. You called storageState() after context.close(), or inside an afterAll hook that ran once the fixture had already torn the context down. Serialise before closing.

The state file exists but origins is []. The context never committed anything to localStorage for the origin, usually because you saved before the app finished bootstrapping, or because the token is in sessionStorage. Add a real assertion — await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible() — immediately before saving so the snapshot cannot be taken too early.

A test intermittently sees another test's data. Two workers share an account. This is the classic symptom that per-worker or per-role state solves; the general diagnostic approach for order-dependent failures is in Flaky Test Management.

The suite passes locally and fails on the first CI run of the day. The token was minted when the file was captured, and the file was cached. Either the cache restored a session the backend has since invalidated, or the environment was rebuilt overnight and its session store is empty. Regenerate state per run and the symptom disappears permanently.

Cookies are set but never sent. Check sameSite and secure. A cookie marked Secure is dropped over plain HTTP, and SameSite=None without Secure is rejected outright by Chromium. If your local environment runs on HTTP while staging runs on HTTPS, a state file captured on one will not work on the other.

When the written error is not enough, the Playwright Trace Viewer settles it. Because a setup project is a real test, its trace is recorded like any other: open it, scrub to the click on the sign-in button, and read the network tab for the Set-Cookie header and the redirect chain. If the header is absent, the problem is your credentials or the backend, not Playwright.

CI/CD considerations

In CI the state file is an ephemeral secret with a short life, and it should be treated that way. Add playwright/.auth/ to .gitignore, and if you use outputDir for per-worker files, confirm the artifact upload step does not sweep them into a downloadable ZIP — a state file inside a public build artifact is a credential published to the internet.

The other side of that coin is the trace. A trace recorded during the setup project contains the login request, and Playwright does not redact request bodies. If the setup test posts a password to /api/session, that password is inside trace.zip, and trace.zip is usually uploaded as a build artifact anyone with repository read access can download. Either restrict tracing on the setup project to retain-on-failure and keep those artifacts private, or authenticate with a short-lived service token minted for the run rather than a reusable password.

Credentials arrive as masked environment variables injected from the platform's secret store, never from a checked-in .env. Assert their presence at the top of the setup test so a missing secret produces E2E_PASSWORD is not set instead of a login form that silently rejects undefined.

Sharding changes the arithmetic. Each shard is a separate process on a separate machine with its own filesystem, so the setup project runs once per shard: eight shards means eight logins per pipeline run. That is usually fine, but against a rate-limited identity provider it can trip a lockout, and against a provider that invalidates prior sessions on new login it can invalidate the shard that logged in first. Either provision a distinct account per shard using the shard index, or authenticate through the API endpoint where the cost is negligible. The sharding mechanics themselves are in Running Playwright Tests in GitHub Actions with Sharding.

Resist caching state files between pipeline runs. The saving is seconds, and the failure mode — a cached session that a backend deploy invalidated, restored into a run that then fails everywhere — costs far more than it saves. Cache the browser binaries instead, as described in CI/CD Integration.

Finally, decide what a failed login should do to the pipeline. Because dependent projects are skipped rather than failed, a naive summary can report "0 failures" when nothing ran. Gate the pipeline on the setup project's result explicitly, and give the setup test retries: 2 so a single flaky redirect from the identity provider does not sink an otherwise green build.

Deep dives beneath this guide

Four detailed procedures build on the model above.

Setting Up an Auth Setup Project takes an empty playwright.config.ts and grows it into a setup-and-teardown dependency graph, including how ordering behaves when several setup projects exist.

Reusing Login State with storageState dissects the JSON format field by field and shows how to load, edit, merge, and validate a state file without ever opening a browser.

Handling OAuth and SSO Redirects in Tests works through third-party redirect chains, generated TOTP codes, and consent screens that only appear on first login.

Testing Multiple User Roles in Parallel builds the role matrix — one project per persona, disjoint data per account, and assertions that a viewer genuinely cannot perform an editor's action.

If you are automating extraction rather than testing, the same session artifact powers a scraper; Scraping Data Behind Login Sessions applies these ideas outside the test runner.

Frequently Asked Questions

Does storageState capture HttpOnly cookies?

Yes. storageState() reads the cookie jar from the browser context over the DevTools protocol rather than from page JavaScript, so HttpOnly cookies — which document.cookie can never see — are serialised along with everything else. This is why the cookie-based approach usually works with no extra effort, and why an application that keeps its session in an HttpOnly cookie is the easiest kind to automate.

Why does my saved session work locally but not in CI?

Almost always an origin mismatch. Cookies are scoped by domain, so a file captured against http://localhost:3000 carries cookies the browser will refuse to send to https://staging.example.com. Regenerate the state inside the CI job against the same baseURL the tests will use, rather than committing a file captured on a laptop. The second most common cause is a Secure cookie captured over HTTPS and replayed over plain HTTP, where the browser silently drops it.

Should I use globalSetup or a setup project?

Prefer a setup project in almost every case. It runs as a real test, so it gets fixtures, retries, tracing, screenshots on failure, and a line in the report — which means a broken login is debuggable from the artifacts instead of a bare stack trace. globalSetup is still useful for work that is not browser-shaped, such as seeding a database or starting a stub server, but authentication belongs in a setup project.

How do I keep tokens from expiring mid-run?

Check the cookie's expires field before the suite starts and re-authenticate when it falls inside a safety margin of roughly ten minutes, rather than assuming a file written earlier is still valid. Pair that with a cheap authenticated API call that asserts a 200, which catches the case where the backend invalidated the session even though the cookie has not reached its expiry.

Can one test act as two different users at once?

Yes, by creating two contexts from the same browser with different storageState files. Each context has an independent cookie jar and independent storage, so an admin and a member can act concurrently in the same test without either observing the other's session. Close both contexts at the end of the test so the worker's memory does not grow across a long run.

Back to overview