Skip to content

Module 10: Authentication & Storage State

Welcome to Module 10. You’ve built POMs, fixtures, API tests, and CI pipelines. Now we tackle the single biggest performance bottleneck in a real test suite: logging in before every test.

By the end of this module you’ll log in once, reuse that authenticated state across your entire suite, and understand exactly which reuse technique fits which situation — for both token-based apps and the HTTP-only session app we practise on.

🎬 Video coming soon


Why logging in every test is slow and brittle

Section titled “Why logging in every test is slow and brittle”

Most test suites start with a beforeEach that navigates to the login page, fills credentials, and submits the form. It feels safe and predictable. In practice it creates two serious problems.

The time problem. A typical UI login takes 1–3 seconds. Multiply that by 50 tests and you lose 2–3 minutes per run before a single assertion fires. At 200 tests the overhead exceeds 10 minutes.

The flakiness problem. Every login attempt crosses the network, fills form fields, waits for redirects, and depends on the server responding within a tight timeout. Each of those steps can fail independently. A flaky login is not a test bug — it looks like one, though, and every debug session traces back to the same place.

Playwright gives us two ways to avoid repeating login: storageState (serialise the authenticated state to disk and replay it) and an authenticated fixture (log in once inside a reusable browser context and hand the logged-in page to every test). This module teaches both, and — crucially — when each one fits.

Remember: logging in per test is the biggest, flakiest tax on a suite’s runtime — log in once and reuse the result; storageState and an authenticated fixture are the two ways to do it.


When a user logs in through a browser, the server sends back proof of identity — typically one or more of these:

  • A session cookie set via the Set-Cookie response header
  • A JWT token stored in localStorage or a cookie
  • A combination of both

Playwright’s storageState serialises that proof into a single JSON file with two top-level keys:

{
"cookies": [
{
"name": "connect.sid",
"value": "s%3Aabc123...",
"domain": "localhost",
"path": "/",
"httpOnly": true
}
],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{ "name": "token", "value": "eyJhbGciOi..." }
]
}
]
}

Saving state is done after a successful login:

await page.context().storageState({ path: 'auth/customer.json' });

Note the call is on page.context(), not on pagestorageState is a BrowserContext method.

Loading state is done when you create a new context (or through config, which does it automatically):

const context = await browser.newContext({
storageState: 'auth/customer.json',
});

Before any page loads in that context, Playwright pre-populates cookies and localStorage from the file. The app sees an already-authenticated browser.

Remember: storageState is a BrowserContext snapshot of cookies + localStorage — save it after login, point a new context (or a project) at the file, and the app loads already authenticated.


storageState and HTTP-only session cookies

Section titled “storageState and HTTP-only session cookies”

A common myth is that storageState can’t handle session-cookie apps because the cookie is HttpOnly. That is false — and it’s worth understanding why, because TestMarket Lab is exactly such an app.

The confusion comes from document.cookie: JavaScript in the page genuinely cannot read an HttpOnly cookie. But storageState doesn’t use document.cookie. It reads the browser’s own cookie jar through the DevTools protocol, so it captures every cookie in the context — HttpOnly, Secure, all of them.

TestMarket Lab authenticates with a server-side session. It uses express-session, whose cookie (connect.sid) is HttpOnly. Log in through the UI, save storageState, and the file does contain connect.sid; load it into a fresh context and the app treats you as logged in. The classic “log in once, save storageState, reuse it everywhere” recipe works here just like it does for token apps.

Verify it yourself: after a UI login, run console.log(await page.context().storageState()) and you’ll see connect.sid in the cookies array with "httpOnly": true.

There is a real caveat, just a different one. A session cookie is only a key into server-side session storage. The saved file goes stale if that session ends — the server restarts, the session expires, or the user logs out. A saved storageState from yesterday’s run can authenticate to nothing today. The fix is to generate the state fresh at the start of each run (a setup project does this for you), not to avoid storageState.

Remember: storageState captures HttpOnly cookies too (it reads the cookie jar, not document.cookie), so it works for TestMarket Lab’s connect.sid — the real limit is staleness: regenerate the file each run because it’s only a key to a server-side session.


The authenticated-fixture pattern (a live-context alternative)

Section titled “The authenticated-fixture pattern (a live-context alternative)”

storageState replays a saved session. Sometimes you’d rather hold a live one: log in once inside a BrowserContext, then hand that already-authenticated context (and its page) to each test through a fixture. Because the context is alive for the whole test, there’s no saved file to go stale — handy for role-switching and local iteration.

fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import type { Page } from '@playwright/test';
async function login(page: Page, email: string, password: string) {
await page.goto('/auth/login');
await page.fill('#email', email);
await page.fill('#password', password);
await page.click('#login-btn');
// The server redirects after login and shows a success flash.
await page.waitForURL((url) => !url.pathname.startsWith('/auth/login'));
}
type AuthFixtures = {
customerPage: Page;
adminPage: Page;
};
export const test = base.extend<AuthFixtures>({
customerPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await login(page, 'customer@test.io', 'customer123');
await use(page);
await context.close();
},
adminPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await login(page, 'admin@test.io', 'admin123');
await use(page);
await context.close();
},
});

Usage in a test — no login code, just ask for the role you need:

import { test } from '../fixtures/auth.fixture';
import { expect } from '@playwright/test';
test('logged-in customer sees the profile link', async ({ customerPage }) => {
await customerPage.goto('/');
await expect(customerPage.locator('[data-testid="nav-profile"]')).toBeVisible();
});
test('admin can reach the admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin');
await expect(adminPage.locator('h1')).toContainText('Admin');
});
test('customer cannot reach the admin panel', async ({ customerPage }) => {
await customerPage.goto('/admin');
// No /unauthorized route — the app redirects non-admins to home with a flash error.
await expect(customerPage).toHaveURL('/');
await expect(customerPage.locator('[data-testid="flash-error"]')).toBeVisible();
});

Each fixture is test-scoped here: it creates a fresh context per test that needs it. That is clean and isolated, but it does log in once per test. The next section shows how to amortise the login cost across a whole worker.

Worker-scoped login (log in once per worker)

Section titled “Worker-scoped login (log in once per worker)”

If you have many tests that all need the same role, pay the login cost once per worker instead of once per test. Mark the fixture { scope: 'worker' } and reuse its context:

fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import type { Page, BrowserContext } from '@playwright/test';
type WorkerAuthFixtures = {
customerContext: BrowserContext;
};
type TestAuthFixtures = {
customerPage: Page;
};
export const test = base.extend<TestAuthFixtures, WorkerAuthFixtures>({
// One logged-in context per worker process.
customerContext: [async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/auth/login');
await page.fill('#email', 'customer@test.io');
await page.fill('#password', 'customer123');
await page.click('#login-btn');
await page.waitForURL((url) => !url.pathname.startsWith('/auth/login'));
await page.close();
await use(context);
await context.close();
}, { scope: 'worker' }],
// A fresh page in the shared authenticated context for each test.
customerPage: async ({ customerContext }, use) => {
const page = await customerContext.newPage();
await use(page);
await page.close();
},
});

Tests written against customerPage look identical to before — they just no longer pay a login per test. The session stays alive inside the shared worker context for the whole worker’s lifetime.

Remember: an authenticated fixture holds a live logged-in context instead of a saved file — reach for it when you want guaranteed-fresh state or easy role-switching; scope it worker to log in once per worker rather than once per test.


For most suites the cleanest “log in once” is a dedicated setup project that other projects declare via dependencies: it logs in through the UI once, saves storageState, and every dependent project loads that file. For TestMarket Lab this is the recommended approach — the saved file carries the real connect.sid session.

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'auth/customer.json' },
dependencies: ['setup'],
},
],
});
tests/setup/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate as customer', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('#email', 'customer@test.io');
await page.fill('#password', 'customer123');
await page.click('#login-btn');
await expect(page.locator('.alert-success')).toBeVisible();
// Saves connect.sid into the file — works because the UI login set the session cookie.
await page.context().storageState({ path: 'auth/customer.json' });
});

The setup-project advantages are real:

  • The setup test appears in the HTML reporter — you can see traces and screenshots if it fails
  • The dependency graph is explicit: chromium runs only after setup passes
  • Multiple projects can depend on the same setup project
  • It runs once per run, so it also re-creates a fresh session each run — exactly the antidote to the staleness caveat

(globalSetup — a single function in the config — is the older way to do the same thing. A setup project is usually preferable because it’s visible in the reporter and integrates with the dependency graph.)

Remember: a setup project logs in once, saves storageState, and dependent projects load it via use.storageState — for TestMarket Lab this is the recommended path, and running once per run keeps the session fresh.


Real applications have multiple user roles with different permissions. With the setup-project approach you write one setup test per role and wire each saved file to its own project:

tests/setup/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authDir = path.join(process.cwd(), 'auth');
setup('authenticate as customer', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('#email', 'customer@test.io');
await page.fill('#password', 'customer123');
await page.click('#login-btn');
await expect(page.locator('.alert-success')).toBeVisible();
await page.context().storageState({ path: path.join(authDir, 'customer.json') });
});
setup('authenticate as admin', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('#email', 'admin@test.io');
await page.fill('#password', 'admin123');
await page.click('#login-btn');
await expect(page.locator('.alert-success')).toBeVisible();
await page.context().storageState({ path: path.join(authDir, 'admin.json') });
});
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { storageState: 'auth/customer.json' },
dependencies: ['setup'],
},
{
name: 'admin-chromium',
use: { storageState: 'auth/admin.json' },
dependencies: ['setup'],
},
],

(With the fixture approach, each role is just another fixture — customerPage and adminPage, as you saw above.)

Projects without dependencies run in parallel. Projects that declare dependencies wait for every listed project to finish and pass before they start. If setup fails, the dependent projects are skipped — you won’t see misleading failures caused by a missing auth file.

Remember: one setup test + one saved file + one project per role keeps roles isolated; dependencies makes the role projects wait for setup and skip cleanly if it fails.


Both techniques work for TestMarket Lab. Choose by what the suite needs, not by the cookie type:

ConcernstorageState + setup projectAuthenticated fixture
Works with HTTP-only session cookieYes (cookie is serialised)Yes (live context)
Works with token / localStorage authYesYes
Login costOnce per runOnce per test, or once per worker
Guards against stale saved stateRe-runs each runN/A — always a live session
Config change neededYes (projects + dependencies)No
Visible in HTML reporterYes (own setup phase)Per test
Multi-role supportOne project per roleOne fixture per role
Best forLarge CI suites, fewest loginsRole-switching, local iteration, guaranteed-fresh state

For a large CI run, the setup-project + storageState column usually wins on speed (one login for the whole run). For heavy role-switching or quick local work, the fixture is convenient.

Remember: for TestMarket Lab both work — default to storageState + setup project for the fewest logins in CI, and reach for the fixture when you want a live context or easy role-switching.


UI login is convenient but slow (~1–3 seconds). When an app exposes a login API that returns a usable credential, you can authenticate programmatically and skip the form entirely. The exact mechanics depend on what the endpoint gives back:

  • Token in the response body → read it, store it (localStorage or a cookie), then save storageState.
  • Session cookie set by the API → call it from a browser context so the Set-Cookie lands there, then keep using that context.

TestMarket Lab’s POST /api/auth/login does neither: it returns user data only ({ id, email, name, role }) — no token — and it does not set a session cookie. So API login can’t seed auth for this app; it’s useful for asserting API behaviour, but for authenticated setup here you log in through the UI (then save storageState, or use a fixture).

When a token-based API is available, the speed win is real:

// Illustrative: only for an app whose /api/auth/login returns a token.
const response = await request.post('http://localhost:3000/api/auth/login', {
data: { email: 'customer@test.io', password: 'customer123' },
});
expect(response.ok()).toBeTruthy();
const { token } = await response.json(); // exists only on token-based APIs
const context = await browser.newContext();
await context.addInitScript((t) => localStorage.setItem('token', t), token);
await context.storageState({ path: 'auth/customer.json' });
await context.close();

When API login is the right choice:

  • Token-based APIs where the response gives you something to store
  • High-volume setups where setup time is measurable in CI
  • Applications with long, multi-step UI login flows

When to stick with UI login (saved via storageState, or a fixture):

  • Server-side session apps whose API returns no token or cookie (like TestMarket Lab)
  • SSO, OAuth2, or multi-factor flows with no direct API endpoint
  • When verifying the actual login UI is part of your coverage

Remember: API login only helps when the endpoint hands back something you can store (a token) or sets a cookie — TestMarket Lab’s /api/auth/login returns neither, so authenticate through the UI and save storageState.


Security — do not commit real credentials

Section titled “Security — do not commit real credentials”

Saved auth state and credentials are sensitive. Treat them like passwords.

Add auth/ to .gitignore — a saved storageState file is a live session, as good as a password:

.gitignore
auth/

Use .env for credentials, never hard-code them:

.env
CUSTOMER_EMAIL=customer@test.io
CUSTOMER_PASSWORD=customer123
tests/setup/auth.setup.ts
import 'dotenv/config';
// ...inside the setup:
await page.fill('#email', process.env.CUSTOMER_EMAIL!);
await page.fill('#password', process.env.CUSTOMER_PASSWORD!);

In CI, set credentials as encrypted environment variables in your provider (GitHub Actions Secrets, GitLab CI/CD Variables, etc.). Generate auth/*.json fresh at the start of each pipeline run and never commit it.

Remember: a saved auth/*.json is a live session — .gitignore the auth/ folder, keep credentials in .env / CI secrets, and regenerate the state each run instead of committing it.


These exercises run against the locally cloned TestMarket Lab at http://localhost:3000 (the app you set up in Module 2). Start it with npm start before running tests. You’ll build the recommended storageState + setup-project flow first, then the live-fixture alternative.

Exercise 1 — Auth setup project (storageState)

Section titled “Exercise 1 — Auth setup project (storageState)”

Create tests/starter/tests/setup/auth.setup.ts and wire it into playwright.config.ts:

  1. A setup project with testMatch: /auth\.setup\.ts/
  2. A chromium project with use: { storageState: 'auth/customer.json' } and dependencies: ['setup']
  3. The setup test logs in (#email / #password / #login-btn), asserts .alert-success is visible, then await page.context().storageState({ path: 'auth/customer.json' })

Run it and confirm auth/customer.json is written and contains a connect.sid cookie. Add auth/ to .gitignore.

Exercise 2 — A test that starts logged in (no login code)

Section titled “Exercise 2 — A test that starts logged in (no login code)”

Create tests/starter/tests/profile.spec.ts. With the setup project wired up, the chromium project already loads storageState, so the test starts authenticated:

import { test, expect } from '@playwright/test';
test('customer sees the profile link', async ({ page }) => {
await page.goto('/');
await expect(page.locator('[data-testid="nav-profile"]')).toBeVisible();
});

There should be no page.fill('#email', ...) in the test body — the saved state handled login.

Add an authenticate as admin setup test that saves auth/admin.json, and an admin-chromium project using it. Write a test that visits /admin and asserts the h1 contains Admin.

Exercise 4 — Customer is denied the admin panel

Section titled “Exercise 4 — Customer is denied the admin panel”

The app has no /unauthorized route. A non-admin hitting /admin is redirected to / (home) with a flash error. Using the customer state:

import { test, expect } from '@playwright/test';
test('customer cannot reach the admin panel', async ({ page }) => {
await page.goto('/admin');
await expect(page).toHaveURL('/');
await expect(page.locator('[data-testid="flash-error"]')).toBeVisible();
});

Exercise 5 — The live-fixture alternative

Section titled “Exercise 5 — The live-fixture alternative”

Write tests/starter/fixtures/auth.fixture.ts exporting a test with customerPage and adminPage fixtures (new context → log in → use(page) → close). Use it for a test where you want a guaranteed-fresh session rather than a saved file. Remember to import type { Page } from '@playwright/test';.

Maintain tests/starter/tests/login.spec.ts that performs a full browser login with the plain test from @playwright/test (no saved state, no fixture). This protects you from silent breakage in the login form:

import { test, expect } from '@playwright/test';
test('login flow works end-to-end', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('#email', 'customer@test.io');
await page.fill('#password', 'customer123');
await page.click('#login-btn');
await expect(page.locator('.alert-success')).toBeVisible();
});

Refactor a customerPage fixture so login happens once per worker instead of once per test (split it into a worker-scoped customerContext and a test-scoped customerPage, as shown in the “Worker-scoped login” section). Confirm with --workers=1 that login runs a single time for several customer tests.

  1. Does storageState capture an HttpOnly session cookie like connect.sid — and why or why not?
  2. What is the real caveat of saving storageState for a server-side session app, and how does a setup project address it?
  3. When is an authenticated fixture a better fit than a saved storageState file?
  4. What is the difference between a test-scoped and a worker-scoped auth fixture?
  5. Why must you import type { Page } in the fixture file?
  6. Where does a non-admin land after requesting /admin, and what proves it failed?
  7. Why should auth/ be in .gitignore?
  8. Why can’t TestMarket Lab’s POST /api/auth/login be used to seed authenticated state?