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.
The storageState concept
Section titled “The storageState concept”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-Cookieresponse header - A JWT token stored in
localStorageor 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 page — storageState 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 seeconnect.sidin thecookiesarray 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.
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:
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.
globalSetup and setup projects
Section titled “globalSetup and setup projects”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.
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'], }, ],});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:
chromiumruns only aftersetuppasses - 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.
Multiple roles
Section titled “Multiple roles”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:
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.
Choosing between the techniques
Section titled “Choosing between the techniques”Both techniques work for TestMarket Lab. Choose by what the suite needs, not by the cookie type:
| Concern | storageState + setup project | Authenticated fixture |
|---|---|---|
| Works with HTTP-only session cookie | Yes (cookie is serialised) | Yes (live context) |
| Works with token / localStorage auth | Yes | Yes |
| Login cost | Once per run | Once per test, or once per worker |
| Guards against stale saved state | Re-runs each run | N/A — always a live session |
| Config change needed | Yes (projects + dependencies) | No |
| Visible in HTML reporter | Yes (own setup phase) | Per test |
| Multi-role support | One project per role | One fixture per role |
| Best for | Large CI suites, fewest logins | Role-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.
API login
Section titled “API login”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 (
localStorageor a cookie), then savestorageState. - Session cookie set by the API → call it from a browser context so the
Set-Cookielands 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:
auth/Use .env for credentials, never hard-code them:
CUSTOMER_EMAIL=customer@test.ioCUSTOMER_PASSWORD=customer123import '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.
Exercises
Section titled “Exercises”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:
- A
setupproject withtestMatch: /auth\.setup\.ts/ - A
chromiumproject withuse: { storageState: 'auth/customer.json' }anddependencies: ['setup'] - The setup test logs in (
#email/#password/#login-btn), asserts.alert-successis visible, thenawait 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.
Exercise 3 — Multiple roles
Section titled “Exercise 3 — Multiple roles”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';.
Exercise 6 — Keep one UI login test
Section titled “Exercise 6 — Keep one UI login 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();});Challenge — Worker-scoped fixture login
Section titled “Challenge — Worker-scoped fixture login”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.
Self-check questions
Section titled “Self-check questions”- Does
storageStatecapture anHttpOnlysession cookie likeconnect.sid— and why or why not? - What is the real caveat of saving
storageStatefor a server-side session app, and how does a setup project address it? - When is an authenticated fixture a better fit than a saved
storageStatefile? - What is the difference between a test-scoped and a worker-scoped auth fixture?
- Why must you
import type { Page }in the fixture file? - Where does a non-admin land after requesting
/admin, and what proves it failed? - Why should
auth/be in.gitignore? - Why can’t TestMarket Lab’s
POST /api/auth/loginbe used to seed authenticated state?