Skip to main content
Playwright intermediate Lesson 6 of 10

Authentication and Reusing Session State

Log in once instead of once per test: storage state files, a setup project with dependencies, and per-worker accounts when tests mutate the logged-in user.

Logging in through the UI in beforeEach is the most common reason a suite that should take two minutes takes twenty. The session is just cookies and localStorage — capture it once, and every test starts already authenticated.

The version to replace

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

test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('correct-horse');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

test('shows the order list', async ({ page }) => {
  await page.goto('/orders');
  await expect(page.getByRole('row')).toHaveCount(4);
});
Running 24 tests using 4 workers

  ✓  1 [chromium] › tests/orders.spec.ts:12:1 › shows the order list (3.4s)

  24 passed (28.1s)

Every one of those 24 tests spent roughly 2.5 seconds on a login form that is tested elsewhere. That is 60 seconds of the 28 — and it grows linearly with the suite.

Save the state once

A setup project is an ordinary spec file that other projects depend on:

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

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

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('correct-horse');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for the session to actually exist before saving it.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

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

export default defineConfig({
  use: { baseURL: 'http://localhost:3000' },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
npx playwright test
Running 25 tests using 4 workers

  ✓  1 [setup] › tests/auth.setup.ts:6:1 › authenticate (2.6s)
  ✓  2 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (721ms)
  ✓  3 [chromium] › tests/orders.spec.ts:9:1 › filters by status (655ms)

  25 passed (9.4s)

28 seconds to 9. dependencies: ['setup'] guarantees the setup project finishes before any chromium test starts, and storageState loads that file into every new context.

The saved file is readable JSON:

cat playwright/.auth/user.json
{
  "cookies": [
    {
      "name": "session",
      "value": "eyJhbGciOiJIUzI1NiIs…",
      "domain": "localhost",
      "path": "/",
      "expires": 1789459200,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    }
  ],
  "origins": [
    {
      "origin": "http://localhost:3000",
      "localStorage": [{ "name": "user.id", "value": "42" }]
    }
  ]
}

Live credentials. Add it to .gitignore before the first run:

echo "playwright/.auth" >> .gitignore
$ git status --short
?? playwright.config.ts
?? tests/auth.setup.ts

The auth directory is absent from the list — which is the point.

Testing login itself

The one spec that must not inherit the session opts out:

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

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

test('rejects a wrong password', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('wrong');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('alert')).toHaveText('Invalid email or password');
  await expect(page).toHaveURL(/\/login$/);
});
  ✓  1 [chromium] › tests/login.spec.ts:6:1 › rejects a wrong password (1.1s)

  1 passed (1.8s)

An empty inline storageState is the documented way to say “clean context” — clearer than pointing at an empty file.

Skipping the UI entirely

If the app issues a session from an API, the setup does not need a browser at all:

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

setup('authenticate via API', async ({ request }) => {
  const response = await request.post('/api/session', {
    data: { email: 'ada@example.com', password: 'correct-horse' },
  });
  if (!response.ok()) {
    throw new Error(`login failed: ${response.status()} ${await response.text()}`);
  }
  // request shares a cookie jar with the context, so the Set-Cookie is captured.
  await request.storageState({ path: 'playwright/.auth/user.json' });
});
  ✓  1 [setup] › tests/auth.setup.ts:4:1 › authenticate via API (218ms)

  1 passed (901ms)

218ms against 2.6 seconds. The trade-off is that you are no longer exercising the login form in setup — which is fine, because login.spec.ts does that deliberately.

For a token in localStorage rather than a cookie, seed it with addInitScript so it is present before any app code runs:

await context.addInitScript(token => {
  localStorage.setItem('auth.token', token);
}, process.env.TEST_TOKEN!);

Several roles

Most apps need more than one logged-in user. Save one file per role and give each a project:

// tests/auth.setup.ts
const roles = [
  { name: 'admin',  email: 'admin@example.com',  file: 'playwright/.auth/admin.json' },
  { name: 'viewer', email: 'viewer@example.com', file: 'playwright/.auth/viewer.json' },
];

for (const role of roles) {
  setup(`authenticate as ${role.name}`, async ({ page }) => {
    await login(page, role.email);
    await page.context().storageState({ path: role.file });
  });
}
// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'admin',
    testMatch: /.*\.admin\.spec\.ts/,
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' },
    dependencies: ['setup'],
  },
  {
    name: 'viewer',
    testMatch: /.*\.viewer\.spec\.ts/,
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/viewer.json' },
    dependencies: ['setup'],
  },
],
Running 14 tests using 4 workers

  ✓  1 [setup] › tests/auth.setup.ts:9:3 › authenticate as admin (2.4s)
  ✓  2 [setup] › tests/auth.setup.ts:9:3 › authenticate as viewer (2.5s)
  ✓  3 [admin] › tests/users.admin.spec.ts:4:1 › can delete a user (834ms)
  ✓  4 [viewer] › tests/users.viewer.spec.ts:4:1 › cannot see the delete button (702ms)

  14 passed (11.2s)

The project name lands in the report line, so a failure tells you which role broke without opening anything.

When tests mutate the user

A shared account plus parallel workers is a data race: one test renames the profile while another asserts on the old name. Give each worker its own account and its own state file:

// tests/fixtures.ts
import { test as base } from '@playwright/test';
import fs from 'node:fs';

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

  workerStorageState: [
    async ({ browser }, use) => {
      const id = test.info().parallelIndex;
      const file = `playwright/.auth/worker-${id}.json`;

      if (!fs.existsSync(file)) {
        const page = await browser.newPage({ storageState: undefined });
        await login(page, `user-${id}@example.com`);
        await page.context().storageState({ path: file });
        await page.close();
      }
      await use(file);
    },
    { scope: 'worker' },
  ],
});
Running 24 tests using 4 workers

  ✓  1 [chromium] › tests/profile.spec.ts:5:1 › renames the account (1.9s)
  ✓  2 [chromium] › tests/profile.spec.ts:11:1 › shows the current name (812ms)

  24 passed (12.7s)

Four logins for 24 tests, and no two workers touching the same row. Overriding the built-in storageState fixture is the trick that makes it apply to every context automatically.

Expiry

A state file older than the session lifetime produces a puzzling failure: the test lands on /login and every locator times out. Two defences — regenerate through the setup project on every run (the default above), and assert the session is live in setup rather than assuming it:

setup('authenticate', async ({ page }) => {
  await login(page);
  await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
  await page.context().storageState({ path: authFile });
});
  ✘  1 [setup] › tests/auth.setup.ts:6:1 › authenticate (5.5s)

    Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
    Locator: getByRole('button', { name: 'Sign out' })

  1 failed
  24 did not run

One clear failure in setup, and the dependent tests are skipped rather than producing 24 mysterious timeouts.

Practice

1. Add a setup project and confirm it runs before the tests that depend on it.
npx playwright test --project=chromium
Running 4 tests using 3 workers

  ✓  1 [setup] › tests/auth.setup.ts:6:1 › authenticate (2.5s)
  ✓  2 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (703ms)
  ✓  3 [chromium] › tests/orders.spec.ts:9:1 › filters by status (688ms)
  ✓  4 [chromium] › tests/orders.spec.ts:14:1 › opens an order (745ms)

  4 passed (4.6s)

Asking for --project=chromium still runs setup — dependencies are pulled in automatically, which is why they are declared in the config rather than on the command line.

2. Make the setup fail and observe what happens to the dependent tests.
  ✘  1 [setup] › tests/auth.setup.ts:6:1 › authenticate (5.4s)

  1 failed
    [setup] › tests/auth.setup.ts:6:1 › authenticate ────────────────────────────
  23 did not run

“did not run” rather than 23 failures. A broken login reports as one problem, which is what you want at 3am — the alternative is a wall of timeouts that all say the same thing.

3. Write a test that asserts a logged-out visitor is redirected from a protected page.
test.use({ storageState: { cookies: [], origins: [] } });

test('redirects anonymous visitors to login', async ({ page }) => {
  await page.goto('/orders');
  await expect(page).toHaveURL(/\/login\?next=%2Forders$/);
  await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
  ✓  1 [chromium] › tests/auth.spec.ts:5:1 › redirects anonymous visitors to login (912ms)

  1 passed (1.6s)

Asserting the next parameter as well as the path catches a regression that loses the return URL — a bug that is invisible if you only check you landed on /login.

4. Inspect the storage state file and remove one cookie by hand, then run a test.
  ✘  1 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (5.6s)

    Error: Timed out 5000ms waiting for expect(locator).toHaveCount(expected)

    Locator: getByRole('row')
    Expected: 4
    Received: 0
    Call log:
      - waiting for getByRole('row')

This is exactly what an expired session looks like, and it explains nothing on its own. When a whole project fails this way, check the auth file before you debug a single test.

Next: network interception — mocking an API so a test does not depend on a backend.

Frequently Asked Questions

What is storageState in Playwright?
A JSON snapshot of a browser context's cookies and localStorage. Save it after logging in once, then start every other test from that state so no test spends time on the login form.
Should the storage state file be committed to git?
No. It holds live session cookies and tokens, so it belongs in `.gitignore` alongside `test-results/`. Regenerate it on every run through a setup project — that also keeps it from going stale when the session expires.
How do I test the login flow itself if every test is already logged in?
Give that spec its own state with `test.use({ storageState: { cookies: [], origins: [] } })`, which starts it from a clean context. Login is one test; every other test should assume it works and skip it.
How do I handle tests that modify the logged-in user?
Give each parallel worker its own account and its own state file, keyed by `test.info().parallelIndex` in a worker-scoped fixture. Sharing one account across workers means one test's profile edit shows up in another's assertions.