Here’s a pattern I see in nearly every Playwright suite I inherit—and it is doing more damage than anyone on the team realizes.
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('[name=email]', 'alice@example.com');
await page.fill('[name=password]', 'password123');
await page.click('button[type=submit]');
await expect(page).toHaveURL('/shelf');
});Every single test logs in through the UI. Every single test opens the login page, fills in the fields, clicks submit, and waits for the redirect. If your suite has a hundred tests and login takes 1.5 seconds, you’ve just spent 150 seconds of wall time doing the same thing over and over. In CI that’s real money. Locally, it’s the reason nobody runs the full suite.
It gets worse. Every one of those logins is also a possible point of failure independent of whatever the test is actually testing. If someone changes the email field’s name attribute, fifty tests break at once, and none of them have anything to do with login. The test output is a wall of red with “element not found” errors, and an agent looking at that wall of red will happily go rewrite fifty tests when the actual fix is to update one selector in one beforeEach.
There’s a better way. It’s not new. It’s in the Playwright docs. It’s called storage state, and almost nobody uses it.
The idea
Log in once, at the start of the test run. Save the resulting browser state—cookies, localStorage, and, if you ask for it, IndexedDB—to JSON. Tell every test to start from that state instead of starting from a blank browser. Now every test opens already logged in, and you’ve paid the login cost exactly once.
Playwright has first-class support for this. You don’t need plugins, you don’t need clever fixtures, you just need to know where the two knobs are.
One naming note from the official authentication docs: the docs use playwright/.auth/ as the conventional directory. Shelf reserves playwright/.authentication/ in its .gitignore for the lab work in this lesson, which is fine. The convention matters less than the rule: keep the files out of git and make the location obvious.
The setup
Create a Playwright setup file—this lab adds tests/authentication.setup.ts to Shelf—that logs in and writes the state to a file. There are two ways to do the “log in” part, and both are worth knowing.
Option A: Log in through the UI
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';
const authenticationFile = path.resolve('playwright/.authentication/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/shelf');
// Save cookies, localStorage, etc., to a file
await page.context().storageState({ path: authenticationFile });
});This is the conservative default and the one Shelf uses. It runs the real login flow—the same page, same form, same redirect—just once per Playwright invocation instead of once per test. The side effect is that it doubles as a smoke test: if someone breaks the login form, the setup fails before any other test runs, and the error is obvious.
Option B: Hit the auth endpoint directly
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';
const authenticationFile = path.resolve('playwright/.authentication/user.json');
setup('authenticate', async ({ request }) => {
// Shelf's login is a SvelteKit form action, not a JSON API endpoint.
// POST to `/login?/signInEmail` with form-encoded credentials.
const response = await request.post('/login?/signInEmail', {
form: { email: 'alice@example.com', password: 'password123' },
});
expect(response.ok()).toBeTruthy();
await request.storageState({ path: authenticationFile });
});No page load, no DOM rendering, no dependency on the login form’s markup. This is faster and more resilient to UI changes. The tradeoff: if someone breaks the login form, the setup still passes, and you won’t find out until a user reports it—or until you have a separate test that exercises the login page directly.
If your app uses a plain JSON auth endpoint instead of a SvelteKit form action, replace the path and switch form: { ... } for data: { ... } so the body is sent as JSON.
Which one?
Use the UI approach when login is fast and you want the free smoke test. Use the API approach when login is slow (OAuth redirects, CAPTCHAs in staging, multi-step flows) or when you already have a dedicated login test and don’t need the setup to double as one.
What the storage state file and object look like
Either way, Playwright can write a JSON file to playwright/.authentication/user.json that looks roughly like this:
{
"cookies": [
{
"name": "shelf_session",
"value": "eyJhbGciOiJIUzI1NiJ9...",
"domain": "127.0.0.1",
"path": "/",
"expires": 1748000000,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://127.0.0.1:4173",
"localStorage": [
{
"name": "shelf:user",
"value": "{\"id\":\"usr_01J\",\"email\":\"alice@example.com\"}"
}
]
}
]
}That file-on-disk shape is also the basic object shape you can pass directly to storageState in code. If you export with indexedDB: true, Playwright can add extra snapshot data for you, but the hand-authored inline form still starts with cookies and origins. You do not have to read from a file if the state is small enough that inlining it is clearer.
import { test } from '@playwright/test';
test.use({
storageState: {
cookies: [
{
name: 'shelf_session',
value: 'test-session-token',
domain: '127.0.0.1',
path: '/',
expires: 1893456000,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
],
origins: [
{
origin: 'http://127.0.0.1:4173',
localStorage: [
{
name: 'shelf:user',
value: JSON.stringify({
id: 'usr_01J',
email: 'alice@example.com',
}),
},
],
},
],
},
});
test('opens already signed in from an inline object', async ({ page }) => {
await page.goto('/shelf');
// Already authenticated without reading a file.
});That is useful when you want a tiny synthetic state for one spec file, when you want to force a known logged-out state, or when the filesystem dependency is more ceremony than value.
import { test } from '@playwright/test';
test.use({ storageState: { cookies: [], origins: [] } });Every field in the inline object matters. In current Playwright docs and the installed Playwright 1.59.x types in this repository, the object form accepts exactly two top-level keys: cookies and origins.
cookies
This is an array of cookie objects to preload into the browser context.
name: The cookie name.value: The cookie value.domain: Which host the cookie belongs to. Prefix with.if you want it to apply to subdomains too, such as.example.com.path: Which URL path the cookie applies to.'/'means the whole site.expires: Expiration time as a Unix timestamp in seconds.httpOnly: Whether JavaScript in the page can read the cookie.truemeansdocument.cookiecannot see it.secure: Whether the cookie should only be sent over HTTPS.sameSite: Cross-site behavior.'Strict'is the most restrictive,'Lax'is the common default, and'None'allows cross-site usage.
origins
This is an array of origin-scoped storage buckets. Each one maps to one exact browser origin.
origin: The exact scheme, host, and port, such ashttp://localhost:5173. If the page runs on a different origin, these entries do nothing.localStorage: ThelocalStorageentries to preload for that origin.
localStorage
Each localStorage entry is a name/value pair.
name: The key.value: The stored string value.localStorageis string-only, so objects needJSON.stringify(...).
What is not in this object
This is the part people mix up.
- There is no top-level
pathkey here.pathbelongs tobrowserContext.storageState({ path })when you are exporting state to disk. - There is no top-level
indexedDBkey here either.indexedDB: trueis also an export option onbrowserContext.storageState(...), not something you usually hand-author in the inline input object. - There is no
sessionStoragekey. Playwright’s auth docs are explicit thatsessionStorageis not persisted bystorageState; if your app stores auth there, you need anaddInitScriptrecipe instead. - There is no cookie
urlshortcut in this shape.urlexists onbrowserContext.addCookies(), butstorageStateexpectsdomainandpath.
So the mental model is:
browserContext.storageState({ path, indexedDB }): export statetest.use({ storageState })orbrowser.newContext({ storageState }): consume a file path or an inline object withcookiesandorigins
A few advanced auth-state edges
Three smaller APIs are worth knowing once the basic pattern is in place.
First, if your auth data lives in browser-managed storage beyond cookies and localStorage, export with indexedDB: true:
await page.context().storageState({
path: authenticationFile,
indexedDB: true,
});That matters for SDK-driven auth flows that stash tokens in IndexedDB instead of cookies.
Second, newer Playwright versions add browserContext.setStorageState(). That lets you clear the current cookies, localStorage, and IndexedDB and apply a fresh state to the same context. Most test suites are still better served by “new context per test,” but if you are writing a very deliberate role-switching or reset helper, this is cleaner than trying to mutate the old session piecemeal.
Third, if you test CHIPS or partitioned third-party cookies, cookie objects can carry a partitionKey. Shelf does not need this. It is worth knowing the shape exists before you spend an afternoon wondering why a modern third-party cookie test is missing part of the session model.
Wiring it into the config
This is where Playwright projects earn their keep. If you haven’t read that lesson yet, the short version: a project is a named block in playwright.config.ts with its own settings and dependencies. Playwright runs them in dependency order, like a mini build graph.
For authentication, you split the config into two projects—one that logs in, and one that runs the actual tests:
import { defineConfig } from '@playwright/test';
import path from 'node:path';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /authentication\.setup\.ts/,
},
{
name: 'chromium',
use: {
storageState: path.resolve('playwright/.authentication/user.json'),
},
dependencies: ['setup'],
},
],
});The setup project matches only the authentication setup file. The chromium project depends on setup, so Playwright guarantees the login runs first. Every test in chromium starts with the saved session pre-loaded—no login code, no beforeEach, nothing.
flowchart TD A["Setup project runs once"] --> B["User logs in via UI"] B --> C["Browser state saved<br>to JSON file"] C --> D["Tests run"] D --> E["Each test loads<br>storage state"] E --> F["Tests start<br>already authenticated"] style A fill:#e1f5ff style C fill:#c8e6c9 style F fill:#c8e6c9
Tests that used to begin with a login flow now begin at /shelf already authenticated:
test('rate a book', async ({ page }) => {
await page.goto('/shelf');
// Already logged in. Just do the thing.
await page
.getByRole('article', { name: /Station Eleven/ })
.getByRole('button', { name: 'Rate this book' })
.click();
// ...
});The login code is gone. The login concern is gone. If login breaks, exactly one test fails—the setup test—and the error tells you unambiguously that login itself is broken, not that “everything is flaky.”
Multiple roles
Shelf has an admin surface for featuring books on the home page. So we need two authenticated contexts: a regular user and an admin. Easy:
// authentication.setup.ts
setup('authenticate as user', async ({ page }) => {
// ... log in as alice ...
await page.context().storageState({ path: 'playwright/.authentication/user.json' });
});
setup('authenticate as admin', async ({ page }) => {
// ... log in as admin ...
await page.context().storageState({ path: 'playwright/.authentication/admin.json' });
});Then in the config, two test projects:
projects: [
{ name: 'setup', testMatch: /authentication\.setup\.ts/ },
{
name: 'user',
testMatch: /tests\/user\//,
use: { storageState: 'playwright/.authentication/user.json' },
dependencies: ['setup'],
},
{
name: 'admin',
testMatch: /tests\/admin\//,
use: { storageState: 'playwright/.authentication/admin.json' },
dependencies: ['setup'],
},
];Organize the tests by role under subdirectories. Every test inherits the right state automatically. No decorators, no fixtures-within-fixtures, no clever beforeEach logic.
Third-party providers still end in storage state
If your app signs in through Google OAuth, Okta, Microsoft, Auth0, or some other external identity provider, the storage-state pattern still applies. What changes is not the destination but how you obtain the authenticated state.
The dedicated lesson, Testing Third-Party Authentication, covers the pattern in detail: keep one narrow smoke test for “the redirect starts,” bootstrap your application’s own session for the normal test suite, and reserve full real-provider flows for a separate smoke lane when you truly need them.
The .gitignore you need
The authentication state files contain real cookies. Do not commit them. Add this to .gitignore:
playwright/.authentication/
# or `playwright/.auth/` if you follow the Playwright docs conventionAnd do not skip this step. I have seen session cookies committed to public repos twice. Both times it was an agent that did it. Put the ignore line in before the agent writes the setup file, not after.
When to re-authenticate
Storage state files don’t expire on their own. If your session cookies are short-lived (which they should be), the state file will eventually be stale, and your tests will start failing with “redirected to /login” errors.
Two options exist:
- Regenerate the state file on every Playwright run. The
setupproject approach above does this automatically—everynpx playwright testruns the login again. - Cache the state file with a TTL. There are recipes in the Playwright docs if you want this. I usually don’t—regenerating on every run is cheap enough that I don’t bother with caching.
The setup-project pattern is the safest default for workshop code because it gives you a fresh, browser-real authentication state on every run. Cache the file only when you have measured the setup as a bottleneck and you understand the failure modes around expired cookies.
The agent rules
## Playwright authentication
- Never log in through the UI inside a test. Login happens once, in
`tests/authentication.setup.ts`, and all other tests inherit
the resulting storage state.
- If a test needs a different user or role, add a new setup for that
role and a new Playwright project that depends on it.
- Never commit `playwright/.authentication/`. It contains real session
cookies. The docs call this folder `playwright/.auth/`; the rule is the
same either way.
- If a test is failing because it's redirected to `/login`, the problem
is the setup file or the session cookie TTL, not the individual test.
Do not fix it by adding `page.goto('/login')` to the test.That last rule is specifically there to stop the agent from “fixing” the symptom when the setup is broken. I have watched an agent, given a redirect error, revert six months of storage-state work because it was easier to copy-paste a login block into the failing test. The rule makes that explicitly off-limits.
The one thing to remember
You log in once per run, not once per test. The cost savings are real, the stability improvements are bigger than the cost savings, and the pattern is one of the few places where Playwright’s defaults are genuinely well-designed and almost nobody uses them. Put it in the instructions file. The agent will thank you by not writing a hundred login blocks.