Playwright: Authentication & Isolation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Playwright runs every test in a fresh browser context, so tests do not share cookies or storage, and it can save a signed-in context to a file and start later tests from it. This page explains both halves. It targets Playwright 1.64.1 for Node.js (the Python, Java and .NET examples elsewhere in this section ran at 1.63) and was checked on 25 October 2026. Every example on this page was run against the Bookshelf demo application described in Example Application & Test Layout, in Chromium and WebKit, except where a sentence says otherwise. The project layout and page objects it builds on are in Test Design & Page Objects; configuration, fixtures and projects are covered in Configuration, Fixtures & Projects.

Browser contexts and test isolation

A browser context is an incognito-like profile inside one browser process: its own cookies, localStorage, sessionStorage, IndexedDB, cache, permissions, geolocation, locale and so on. Creating a context takes milliseconds and contexts do not see each other, even in the same browser.

The Playwright Test runner creates one context per test and gives the test a default page in it. The context and page fixtures are therefore always clean, and what one test stores can never leak into the next. The official documentation presents this as the reason to start from scratch instead of cleaning up between tests: cleanup is easy to forget, some state (such as visited links) cannot be cleaned, and a failure is much easier to debug when the only possible cause is inside the failing test. It also means test order does not matter, which parallelism, sharding and retries rely on.

This spec writes to storage in one test and checks it in the next (isolation.spec.ts, excerpt):

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

test('first test writes to storage', async ({ page }) => {
  await page.goto('/');
  await page.localStorage.setItem('draft', 'first');
  await page.sessionStorage.setItem('flag', '1');
  expect(await page.localStorage.getItem('draft')).toBe('first');
});

test('second test starts with empty storage', async ({ page }) => {
  await page.goto('/');
  // A new context was created for this test, so nothing leaked from the previous one.
  expect(await page.localStorage.getItem('draft')).toBeNull();
  expect(await page.sessionStorage.getItem('flag')).toBeNull();
});

Isolation is a property of the context, not of the server. Two contexts that sign in to the same Bookshelf account still share the server-side data (reviews, books), which is why tests that change shared data need their own accounts or a data reset, as discussed under worker-scoped authentication below.

Multiple contexts in one test

A single test can create more contexts with the browser fixture. Use this for scenarios with several users at once, such as a chat or a moderation flow. Contexts created this way are not tracked by the test runner’s automatic cleanup of the context fixture, so close them (or use a fixture that does, as in the role fixtures below).

This test signs in a reader and an administrator side by side. The reader posts a review and the administrator sees it in the back-office list:

test('reader and admin side by side', async ({ browser }) => {
  const readerContext = await browser.newContext({ storageState: 'playwright/.auth/reader.json' });
  const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const readerPage = await readerContext.newPage();
  const adminPage = await adminContext.newPage();

  await readerPage.goto('/account');
  await adminPage.goto('/admin');
  await expect(readerPage.getByTestId('account-role')).toHaveText('Role: reader');
  await expect(adminPage.getByTestId('admin-heading')).toBeVisible();

  // The reader writes a review; the administrator sees it after a reload.
  await readerPage.goto('/books/1');
  await readerPage.getByTestId('review-rating-input').selectOption('5');
  await readerPage.getByTestId('review-text-input').fill('Written by the reader context.');
  await readerPage.getByTestId('review-submit').click();
  await expect(readerPage.getByTestId('review').last()).toHaveAttribute('data-state', 'saved');

  await adminPage.reload();
  await expect(adminPage.getByTestId('all-reviews')).toContainText('Written by the reader context.');

  await readerContext.close();
  await adminContext.close();
});

Options such as viewport, locale, colorScheme, permissions and storageState are per context, so two contexts in one test can also differ in language or theme. In the Playwright Test runner, browser.newContext() starts from the project’s use options, so a context created with no arguments still has the project’s baseURL, viewport and storageState; options you pass override them. This was checked with a context created with no options that opened the relative URL /account and was signed in as the reader:

test('browser.newContext() inherits project options', async ({ browser }) => {
  const context = await browser.newContext(); // no options passed
  const page = await context.newPage();
  await page.goto('/account'); // relative URL: baseURL was inherited
  await expect(page.getByTestId('account-heading')).toHaveText('Hello, reader'); // storageState too
  await context.close();
});

Pass storageState: { cookies: [], origins: [] } to such a context when it must start signed out.

Why save authentication state

Signing in through the user interface in every test is slow, and a broken sign-in page fails every test instead of one. The approach Playwright recommends is to sign in once, save the context’s state to a file and start each test from that file. The state is a JSON snapshot that browserContext.storageState() produces, containing:

  • cookies, with domain, path, expiry, httpOnly, secure and sameSite flags

  • localStorage for every origin the context touched

  • optionally IndexedDB (indexedDB: true, since 1.51), the origin private file system (opfs: true, since 1.63) and virtual passkeys (credentials: true, since 1.62), described in the sections below

Bookshelf’s session is the cookie bookshelf_session (HttpOnly, SameSite=Lax, no expiry), so the saved file for the reader looks like this, with the value being a random session id:

{
  "cookies": [
    {
      "name": "bookshelf_session",
      "value": "<session id>",
      "domain": "localhost",
      "path": "/",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    }
  ],
  "origins": []
}

The file is a credential. Anyone who has it can impersonate the account, so keep it out of version control. The documentation recommends a playwright/.auth directory that is listed in .gitignore:

mkdir -p playwright/.auth
echo $'\nplaywright/.auth' >> .gitignore

A saved state also expires with the session it describes. Bookshelf’s /api/test/reset endpoint clears every server-side session, so a state file saved before a reset no longer signs anyone in. This was observed directly:

curl -s -b bookshelf_session=$SESSION localhost:3915/api/session
# {"authenticated":true,"name":"reader","role":"reader"}
curl -s -X POST localhost:3915/api/test/reset
# {"books":24,"reviews":3}
curl -s -b bookshelf_session=$SESSION localhost:3915/api/session
# {"authenticated":false}

This is why a suite that reuses signed-in state should reset data with /api/test/seed (which leaves sessions alone) or sign in again after a reset, and must not call /api/test/reset from a per-test hook, as the example fixtures in Example Application & Test Layout do for anonymous tests. Delete stale state files when the server’s sessions expire. If you do not need the state to outlive the run, write it under the project’s outputDir, which Playwright cleans before each run.

A setup project that signs in once

The shared-account pattern uses a setup project: a project that runs first, signs in and writes the state file, and that every other project lists in dependencies. The setup project always runs before the projects that depend on it, and its failure skips them.

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

const readerState = 'playwright/.auth/reader.json';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    testIdAttribute: 'data-testid',
    trace: 'on-first-retry',
  },
  projects: [
    // Runs first and writes the storage state files.
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: readerState },
      dependencies: ['setup'],
      testIgnore: /.*\.setup\.ts/,
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'], storageState: readerState },
      dependencies: ['setup'],
      testIgnore: /.*\.setup\.ts/,
    },
  ],
});

The testIgnore lines keep the setup file from also running inside the browser projects. The setup file signs in as the reader through the UI, and as the administrator through the REST API:

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

const readerFile = 'playwright/.auth/reader.json';
const adminFile = 'playwright/.auth/admin.json';

// Signs in through the user interface, the way a user would.
setup('authenticate as reader', async ({ page }) => {
  await page.goto('/signin');
  await page.getByLabel('Username').fill('reader');
  await page.getByLabel('Password').fill(process.env.BOOKSHELF_READER_PASSWORD!);
  await page.getByTestId('signin-submit').click();

  // The cookie is set by the sign-in response; wait for the redirect to finish.
  await page.waitForURL('/account');
  await expect(page.getByTestId('account-role')).toHaveText('Role: reader');

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

// Signs in through the REST API, which is faster and needs no browser page.
setup('authenticate as admin', async ({ request }) => {
  const response = await request.post('/api/session', {
    data: { username: 'admin', password: process.env.BOOKSHELF_ADMIN_PASSWORD! },
  });
  expect(response.ok()).toBeTruthy();

  await request.storageState({ path: adminFile });
});

Passwords come from BOOKSHELF_READER_PASSWORD and BOOKSHELF_ADMIN_PASSWORD and are never written in a test file. A few details matter:

  • Wait for the cookie before saving. Sign-in flows often set cookies across several redirects. Wait for the final URL or for something only a signed-in page shows (the account-role assertion above) before calling storageState().

  • One state file can serve several browsers. The setup project ran once, and the Chromium and WebKit projects both started from its cookie. If your authentication is bound to the browser (for example tokens bound to a user agent), give each browser project its own setup project and file.

  • Ordinary tests then start signed in. With storageState in the project’s use, the page fixture of every test is authenticated:

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

test('reader is signed in', async ({ page }) => {
  await page.goto('/account');
  await expect(page.getByTestId('account-heading')).toHaveText('Hello, reader');
});

The run of the whole example project passed in both browsers:

BASE_URL=http://localhost:3915 npx playwright test -c ex-authentication-and-isolation/playwright.config.ts
# 50 passed

Setup project, global setup or fixtures

The documentation prefers a setup project over globalSetup because a setup project appears in the HTML report and traces, can use fixtures such as page and expect, and its failures are reported like any other test. The related globalSetup file is still supported; see Configuration, Fixtures & Projects for both. A third option is a fixture that signs in lazily, described under worker-scoped authentication below.

UI Mode and the setup project

UI Mode does not run the setup project by default, to keep re-runs fast. If the state file is missing or stale, enable the setup project in the filters, run auth.setup.ts with its triangle button, and disable the filter again. From the command line, --project=setup runs just the setup project.

Worker-scoped authentication

The shared-account approach assumes that tests could all run at once with the same account without affecting each other. When tests modify server-side state (one changes a setting, another renders it), use one account per parallel worker: each worker process authenticates once, and all tests that worker runs reuse the state. The documentation implements this with a worker-scoped fixture that overrides storageState and uses testInfo.parallelIndex to pick an account:

// tests/fixtures.ts
import { test as baseTest, expect, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';

export { expect };

export const test = baseTest.extend<{}, { workerStorageState: string }>({
  // Every test in this worker starts from the worker's saved state.
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  // Authenticate once per worker with a worker-scoped fixture.
  workerStorageState: [async ({}, 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;
    }

    // Sign in from a clean environment.
    const context = await request.newContext({
      baseURL: test.info().project.use.baseURL,
      storageState: undefined,
    });
    // Bookshelf has two accounts; a real suite would hand out one account per worker here.
    const response = await context.post('/api/session', {
      data: { username: 'reader', password: process.env.BOOKSHELF_READER_PASSWORD! },
    });
    expect(response.ok()).toBeTruthy();
    await context.storageState({ path: fileName });
    await context.dispose();
    await use(fileName);
  }, { scope: 'worker' }],
});

A spec that imports test from this file starts signed in without any storageState in the config:

import { test, expect } from './fixtures';

for (const id of [1, 2, 3, 4]) {
  test(`worker-authenticated test ${id}`, async ({ page }, testInfo) => {
    await page.goto(`/books/${id}`);
    await expect(page.getByTestId('review-form')).toBeVisible();
    console.log(`test ${id} ran on worker ${testInfo.parallelIndex}`);
  });
}

With five workers, each of the four tests signed in once on whichever worker ran it, and every test saw the review form that only signed-in users get. Because the state file lives under the project’s outputDir, it is deleted at the start of the next run, so the state is always fresh. Bookshelf has only two accounts, so the example reuses the reader; in a real suite, acquireAccount(parallelIndex) would return a unique account per worker (created on demand or taken from a pool), and accounts must not be shared between team members who run the suite at the same time. Two cautions from the documentation: authenticate from a clean environment (storageState: undefined) so the project’s own state does not interfere, and use parallelIndex, not workerIndex, because workerIndex changes when a worker restarts after a failure while parallelIndex stays within 0..workers-1.

Several roles

When tests need different roles but may share one account per role, sign in once per role in the setup project, as in auth.setup.ts above, and choose the file where the role is needed instead of in the config. test.use() at the top of a file applies to every test in it, and inside a test.describe to that group:

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

// File level: every test below runs as the reader unless a describe overrides it.
test.use({ storageState: 'playwright/.auth/reader.json' });

test('reader is signed in', async ({ page }) => {
  await page.goto('/account');
  await expect(page.getByTestId('account-heading')).toHaveText('Hello, reader');
});

test('reader cannot open the admin page', async ({ page }) => {
  await page.goto('/admin');
  await expect(page.getByTestId('forbidden')).toBeVisible();
});

test.describe('as administrator', () => {
  test.use({ storageState: 'playwright/.auth/admin.json' });

  test('admin sees the statistics', async ({ page }) => {
    await page.goto('/admin');
    await expect(page.getByTestId('admin-stats')).toContainText('books');
  });
});

If most of the suite uses one role, leave that role’s storageState in the project (as the configuration above does for the reader) and override it only where needed. If no role is the default, omit storageState from the project and set it per file. test.use() can appear only at the top level of a file or inside a describe, not inside a test.

Several roles in one test

To drive two roles at once, create two contexts from two state files, as in the "reader and admin side by side" test above. Remember to close the contexts you open.

Fixtures for several roles

To avoid repeating the context set-up, write fixtures that provide a page already signed in as each role, optionally wrapped in a page object (see Test Design & Page Objects). A fixture also closes its context after the test, so nothing leaks:

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

type RoleFixtures = { readerPage: Page; adminPage: Page };

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

This fixture variant follows the pattern in the official guide but, unlike the examples above, was not run as a separate spec; it uses the same state files and context options as the "side by side" test, which was run.

Testing the signed-out experience

A project-wide storageState also applies to tests that need to be signed out, such as the sign-in page itself. Reset it for a file or a group with an empty state:

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

// Reset the project's storage state so these tests start signed out.
test.use({ storageState: { cookies: [], origins: [] } });

test('anonymous visitors are sent to the sign-in page', async ({ page }) => {
  await page.goto('/account');
  await expect(page).toHaveURL(/\/signin\?next=\/account$/);
});

test('sign-in page shows an error for a wrong password', async ({ page }) => {
  await page.goto('/signin');
  await page.getByLabel('Username').fill('reader');
  await page.getByLabel('Password').fill('not-the-password');
  await page.getByTestId('signin-submit').click();
  await expect(page.getByTestId('signin-error')).toBeVisible();
});

Without the reset, Bookshelf would redirect a signed-in reader away from /signin to /account and the form would never appear. The same reset suits tests of registration, password reset, the public catalogue and any redirect-when-anonymous rule. It is also the way to test the sign-in flow itself: the setup project exercises the happy path once, and dedicated signed-out specs cover the failures.

Signing in with the request fixture

When the application has an API sign-in, authenticating through it is faster and less fragile than driving the form. APIRequestContext sends the request and can save its cookies with request.storageState() (as auth.setup.ts does for the administrator). The cookie-sharing rules are easy to get wrong, so this was run and observed:

  • page.request and context.request are the same object, and its cookie jar is the browser context’s. Signing in through it signs the page in.

  • The request fixture of the test runner is a separate API context with its own cookie jar. Signing in through it signs only that fixture in; the browser page stays signed out.

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

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

test('page.request shares cookies with the page', async ({ page }) => {
  // page.request is the same object as context.request: its cookie jar is the context's.
  const response = await page.request.post('/api/session', {
    data: { username: 'reader', password: process.env.BOOKSHELF_READER_PASSWORD! },
  });
  expect(response.ok()).toBeTruthy();

  const cookies = await page.context().cookies();
  expect(cookies.map((c) => c.name)).toContain('bookshelf_session');

  await page.goto('/account');
  await expect(page.getByTestId('account-heading')).toHaveText('Hello, reader');
});

test('the request fixture has its own cookie jar', async ({ page, request }) => {
  await request.post('/api/session', {
    data: { username: 'reader', password: process.env.BOOKSHELF_READER_PASSWORD! },
  });
  // The fixture is signed in...
  expect((await (await request.get('/api/session')).json()).authenticated).toBe(true);
  // ...but the browser page is not.
  await page.goto('/account');
  await expect(page).toHaveURL(/\/signin/);
});

To carry a fixture’s session into the browser, either save its state and start a context from it (request.storageState({ path }) then storageState on the context), or call context.addCookies() with the cookies you need. API testing in general is covered in Network Mocking, Clock & API Testing.

Applying a saved state to a running context

browserContext.setStorageState() (since 1.59) clears the context’s cookies, localStorage and IndexedDB for all origins and loads a new state, with no need to create a new context. It accepts a file path or the object storageState() returns, and is useful to switch the identity of a context mid-test:

test('setStorageState applies a saved state to a running context', async ({ context, page }) => {
  await page.goto('/account');
  await expect(page).toHaveURL(/\/signin/);

  await context.setStorageState('playwright/.auth/reader.json');
  await page.goto('/account');
  await expect(page.getByTestId('account-heading')).toHaveText('Hello, reader');
});

(In this spec file the project state is reset to empty with test.use so that the first navigation is anonymous.)

Session storage and IndexedDB

storageState() captures cookies and localStorage. Two other stores need extra steps.

IndexedDB

Applications such as those using Firebase Authentication keep tokens in IndexedDB. Pass indexedDB: true to include them in the snapshot (since 1.51); restoring the state recreates the databases:

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

The following test writes a value into an IndexedDB database in Bookshelf’s origin, shows that the default snapshot misses it, and that a context created from the indexedDB: true snapshot reads it back:

test('indexedDB: true includes IndexedDB in the snapshot', async ({ page, browser }) => {
  await page.goto('/');
  await page.evaluate(() => new Promise<void>((resolve, reject) => {
    const open = indexedDB.open('auth', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('tokens');
    open.onsuccess = () => {
      const tx = open.result.transaction('tokens', 'readwrite');
      tx.objectStore('tokens').put('secret-token', 'access');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    };
  }));

  const without = await page.context().storageState();
  const withIdb = await page.context().storageState({ indexedDB: true });
  expect(JSON.stringify(without)).not.toContain('secret-token');
  expect(JSON.stringify(withIdb)).toContain('secret-token');

  const restored = await browser.newContext({ storageState: withIdb });
  const restoredPage = await restored.newPage();
  await restoredPage.goto('/');
  const value = await restoredPage.evaluate(() => new Promise((resolve) => {
    indexedDB.open('auth', 1).onsuccess = (e) => {
      const db = (e.target as IDBOpenDBRequest).result;
      db.transaction('tokens').objectStore('tokens').get('access').onsuccess = (g) => resolve((g.target as IDBRequest).result);
    };
  }));
  expect(value).toBe('secret-token');
  await restored.close();
});

The opfs: true option (since 1.63) does the same for the origin private file system; the API reference notes that OPFS is not supported in ephemeral WebKit contexts, and this page did not exercise it.

sessionStorage

sessionStorage is scoped to a tab and a window session and is not part of storageState(); the Playwright documentation says there is no API to persist it and recommends copying it by hand. Save it with page.evaluate() and restore it with an init script that runs before the page’s own scripts, restricted to the right hostname:

test('sessionStorage is not part of storageState', async ({ page, browser }) => {
  await page.goto('/');
  await page.sessionStorage.setItem('flag', 'on');

  const state = await page.context().storageState();
  expect(JSON.stringify(state)).not.toContain('flag');

  // Workaround: copy it by hand into an init script of the next context.
  const saved = await page.evaluate(() => JSON.stringify(sessionStorage));
  const next = await browser.newContext();
  await next.addInitScript((storage) => {
    if (window.location.hostname === 'localhost') {
      for (const [key, value] of Object.entries(storage)) window.sessionStorage.setItem(key, value as string);
    }
  }, JSON.parse(saved));
  const nextPage = await next.newPage();
  await nextPage.goto('/');
  expect(await nextPage.sessionStorage.getItem('flag')).toBe('on');
  await next.close();
});

For a real suite, write the JSON to playwright/.auth/session.json (git-ignored) in the setup project and read it in a fixture that adds the init script. The init script runs in every frame and every navigation of that context, so guard it with a hostname check, and remember that the value is applied again on each load, overwriting changes the page made.

The WebStorage API

Since Playwright 1.61, page.localStorage and page.sessionStorage are WebStorage objects that read and write the storage of the page’s current origin with an async API mirroring the browser’s Storage interface. They save the page.evaluate(() => localStorage...) boilerplate and work in any browser:

Method Behaviour

getItem(name)

Returns the string value, or null when absent.

setItem(name, value)

Sets or overwrites an item.

removeItem(name)

Removes an item; does nothing when it is absent.

items()

Returns all items as an array of name/value objects.

clear()

Removes every item.

Bookshelf’s theme toggle stores the chosen theme in localStorage, which makes it a natural target:

test('WebStorage API on localStorage', async ({ page }) => {
  await page.goto('/');
  await page.getByTestId('theme-toggle').click();

  // The application stores the chosen theme in localStorage.
  const theme = await page.localStorage.getItem('theme');
  expect(['light', 'dark']).toContain(theme);

  await page.localStorage.setItem('token', 'abc');
  console.log(JSON.stringify(await page.localStorage.items()));
  await page.localStorage.removeItem('token');
  expect(await page.localStorage.getItem('token')).toBeNull();
  await page.localStorage.clear();
  expect(await page.localStorage.items()).toEqual([]);
});

The test printed [{"name":"theme","value":"dark"},{"name":"token","value":"abc"}] for the items() call. Navigate to a page of the origin before using it, because the storage belongs to the origin the page currently shows. The WebStorage API changes a running page’s storage; it does not make sessionStorage part of storageState.

The Credentials API and passkeys

Since 1.61, browserContext.credentials is a virtual WebAuthn authenticator scoped to the context. It answers the page’s navigator.credentials.create() and navigator.credentials.get() calls, so tests can register and use passkeys without a physical security key or an operating-system prompt, in every browser. Its methods:

Method Behaviour

install()

Overrides navigator.credentials.create() and get() in all current and future pages of the context. Required: until it is called, the page sees the platform’s native WebAuthn behaviour, and credentials seeded with create() are invisible to it. Call it before the page first touches navigator.credentials.

create(rpId, options)

Seeds a credential for a relying party id and returns it. With only rpId, generates an ECDSA P-256 key pair, credential id and user handle; the credential is discoverable, so both username-then-passkey and usernameless flows find it. To import a known credential supply id, userHandle, privateKey (base64url PKCS#8 DER) and publicKey (base64url SPKI DER) together. signCount (since 1.64) sets the starting signature counter.

get(options)

Returns every credential held, including ones the page registered itself, optionally filtered by rpId or id. Each entry includes the private key and current signCount.

delete(id)

Removes a credential by its base64url id.

The returned objects include private keys, so treat saved ones as secrets exactly like a storage state file.

Bookshelf does not offer passkey sign-in, so there is no relying-party server to complete a full login. The test below runs the two WebAuthn ceremonies directly in a Bookshelf page (localhost is a secure context, so WebAuthn is available) and shows what the virtual authenticator does: registration stores a credential that credentials.get() can read back, and an assertion increments the signature counter.

test('virtual authenticator answers create() and get()', async ({ context, page }) => {
  await context.credentials.install();
  await page.goto('/');

  const registered = await page.evaluate(async () => {
    const credential = (await navigator.credentials.create({
      publicKey: {
        rp: { name: 'Bookshelf', id: 'localhost' },
        user: { id: new Uint8Array([1, 2, 3, 4]), name: 'reader', displayName: 'Reader' },
        challenge: new Uint8Array(32),
        pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
        authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
      },
    })) as PublicKeyCredential;
    return credential.id;
  });

  const [stored] = await context.credentials.get({ rpId: 'localhost' });
  expect(stored.id).toBe(registered);
  expect(stored.signCount).toBe(0);

  const asserted = await page.evaluate(async () => {
    const credential = (await navigator.credentials.get({
      publicKey: { challenge: new Uint8Array(32), rpId: 'localhost', userVerification: 'preferred' },
    })) as PublicKeyCredential;
    return credential.id;
  });
  expect(asserted).toBe(registered);

  const [after] = await context.credentials.get({ id: registered });
  expect(after.signCount).toBe(1);
});

A passkey can also travel in the storage state. storageState({ credentials: true }) (since 1.62) includes the context’s virtual credentials with their private keys and counters, and restoring such a state automatically installs the virtual authenticator (which disables real authenticators in that context). This test seeds a passkey, saves it in the state and uses it from a new context:

test('seed a credential in a fresh context and save it in the storage state', async ({ browser }) => {
  const first = await browser.newContext();
  const seeded = await first.credentials.create('localhost');
  await first.credentials.install();
  const state = await first.storageState({ credentials: true });
  expect(JSON.stringify(state)).toContain(seeded.id);
  await first.close();

  const second = await browser.newContext({ storageState: state });
  const page = await second.newPage();
  await page.goto('/');
  const id = await page.evaluate(async () => {
    const credential = (await navigator.credentials.get({
      publicKey: { challenge: new Uint8Array(32), rpId: 'localhost' },
    })) as PublicKeyCredential;
    return credential.id;
  });
  expect(id).toBe(seeded.id);
  await second.close();
});

Both tests passed in Chromium and WebKit. For a real application, the usual recipe is the one in the API reference: in a setup test, call install(), let the app register a passkey through its own UI, read it back with credentials.get(), and save it (or save the whole state with credentials: true); later tests seed or restore it so the app starts already enrolled. That end-to-end passkey flow against a relying-party server was not run here, because Bookshelf has no passkey endpoints.

HTTP authentication and tokens

Not every protected resource uses a session cookie.

HTTP basic and digest authentication. The httpCredentials context option answers a 401 challenge with the given credentials. An origin restricts which origin receives them, send: 'always' sends the Authorization header with the first request instead of waiting for the challenge, and since 1.63 it accepts an array so different origins can have different credentials (the first matching entry wins). browserContext.setHTTPCredentials() changes them on a running context.

Bearer tokens and API keys. Set extraHTTPHeaders in use (or the context options) and every request from the context and its request fixture carries the header.

Bookshelf uses cookies, so this spec starts a small Node HTTP server inside the test file that requires Basic authentication on /private and echoes the Authorization header on /api:

import { test, expect } from '@playwright/test';
import http from 'http';
import type { AddressInfo } from 'net';

// A tiny protected server: Basic auth on /private, a bearer token on /api.
let server: http.Server;
let origin: string;

test.beforeAll(async () => {
  server = http.createServer((req, res) => {
    const auth = req.headers.authorization ?? '';
    if (req.url === '/private') {
      const expected = 'Basic ' + Buffer.from('reader:' + process.env.BOOKSHELF_READER_PASSWORD).toString('base64');
      if (auth !== expected) {
        res.writeHead(401, { 'www-authenticate': 'Basic realm="demo"' }).end();
        return;
      }
      res.writeHead(200, { 'content-type': 'text/plain' }).end('welcome');
      return;
    }
    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ authorization: auth }));
  }).listen(0);
  origin = `http://localhost:${(server.address() as AddressInfo).port}`;
});
test.afterAll(() => server.close());

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

test.describe('HTTP basic authentication', () => {
  test.use({
    httpCredentials: { username: 'reader', password: process.env.BOOKSHELF_READER_PASSWORD! },
  });

  test('credentials answer the 401 challenge', async ({ page }) => {
    await page.goto(origin + '/private');
    await expect(page.locator('body')).toHaveText('welcome');
  });
});

test('without credentials the page is refused', async ({ page }) => {
  const response = await page.goto(origin + '/private');
  expect(response?.status()).toBe(401);
});

test.describe('bearer token', () => {
  test.use({ extraHTTPHeaders: { Authorization: 'Bearer test-token' } });

  test('extraHTTPHeaders go with every request', async ({ request }) => {
    const response = await request.get(origin + '/api');
    expect(await response.json()).toEqual({ authorization: 'Bearer test-token' });
  });
});

Keep tokens out of playwright.config.ts: read them from environment variables and, in CI, from the secret store, as with the Bookshelf passwords. A token that expires during a run needs a fixture that refreshes it, or a setup project that obtains one short-lived enough token per run and stores it in playwright/.auth.

Pitfalls

  • Committing playwright/.auth. The state files hold live session cookies. Ignore the directory before the first run.

  • A test reset that kills the session. Bookshelf’s /api/test/reset signs everyone out. If the suite resets per test, seed data with /api/test/seed instead, or sign in again after resetting.

  • Saving before the cookie arrives. Wait for the post-login URL or a signed-in element before storageState().

  • Stale state. Expired cookies and server restarts produce redirects to the sign-in page in tests that "worked yesterday". Re-run the setup project; consider failing fast in the setup file when /api/session reports authenticated: false.

  • Parallel tests sharing mutable server state. One shared account is only safe for tests that do not change what other tests read. Use one account per worker, or per test, when they do.

  • Forgetting that request is not page.request. Signing in through the fixture does not sign the page in, as shown above.

  • Project-wide state in signed-out tests. Reset it with test.use({ storageState: { cookies: [], origins: [] } }).

  • Expecting storageState() to capture everything. sessionStorage is never included; IndexedDB, OPFS and passkeys are opt-in.

  • Browser contexts left open. Close the contexts you create with browser.newContext(), or wrap them in a fixture.

  • State bound to the browser or origin. Cookies are per domain and port: a state saved against localhost:3000 does not apply to localhost:3915.

  • Authentication in headed versus headless runs, locally versus CI. The saved file works the same in both, but a CI job starts without it, so the setup project must run there too; use --project=setup or the dependency chain. Do not cache the playwright/.auth directory between CI runs unless sessions outlive the cache.

What changed recently

  • 1.51: storageState({ indexedDB: true }) saves and restores IndexedDB.

  • 1.59: browserContext.setStorageState() replaces a running context’s state without creating a new context.

  • 1.61: the Credentials virtual authenticator (browserContext.credentials) for passkeys, and the WebStorage API (page.localStorage and page.sessionStorage).

  • 1.62: storageState({ credentials: true }) persists virtual passkeys in the storage state.

  • 1.63: storageState({ opfs: true }) includes the origin private file system, and httpCredentials accepts an array of credentials.

  • 1.64: signCount for virtual credentials, returned by credentials.create() and credentials.get() and saved with the state.