storageState captures cookies and localStorage after a real login and writes them to a JSON file. Every subsequent test context loads that file instead of running through the login UI, cutting 2-4 seconds per test on suites where most tests require authentication. This article covers global setup to generate the auth file, the setup project pattern that makes login failures visible in the HTML report, per-project storageState for multiple user roles, worker-scoped fixtures for tests that modify auth state, and API-based login when UI login is the bottleneck.
What storageState actually saves
When you authenticate in a browser, the server proves your identity through one of two mechanisms: a cookie (usually a session ID or a JWT in an HTTP-only cookie), or a token stored in localStorage or sessionStorage. Sometimes both.
Playwright's storageState captures all of it. Calling context.storageState() returns a JSON object containing every cookie in the context and a snapshot of localStorage and sessionStorage for each origin. You can write that JSON to disk, and when Playwright creates a new browser context with storageState: './auth.json', it pre-loads all that data before the first navigation. To the server, the request looks identical to one coming from the original authenticated session.
// What the saved file looks like (abbreviated)
{
"cookies": [
{
"name": "session",
"value": "eyJhbGciOi...",
"domain": "lab.becomeqa.com",
"path": "/",
"expires": 1748000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://lab.becomeqa.com",
"localStorage": [
{ "name": "auth_token", "value": "eyJhbGciOi..." }
]
}
]
}The file is just JSON. You can inspect it, commit it to a test-only branch, or regenerate it on demand. Most teams add it to .gitignore and regenerate at the start of every CI run.
Setting up global-setup.ts
The standard pattern is a global-setup.ts file that runs once before the entire test suite. It launches a browser, performs the real UI login, and saves the resulting state to a file. Every test worker then reads that file instead of logging in.
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
// Wait until we're actually on the authenticated dashboard
await page.getByText('My Travel Items').waitFor({ state: 'visible' });
// Save cookies + localStorage to a file
await context.storageState({ path: 'playwright/.auth/admin.json' });
await browser.close();
}
export default globalSetup;Create the directory before running tests, or Playwright will throw a file-not-found error:
mkdir -p playwright/.authAdd the directory to .gitignore so auth tokens don't end up in version control:
# .gitignore
playwright/.auth/Wiring it into playwright.config.ts
Two things need to happen in your config. First, tell Playwright where global-setup.ts lives. Second, tell every test project to use the saved state as its starting context.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
baseURL: 'https://lab.becomeqa.com',
storageState: 'playwright/.auth/admin.json',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});That's enough to eliminate login from every test. The storageState set in use applies globally, so every browser context Playwright creates will start pre-authenticated.
const ADMIN_AUTH = 'playwright/.auth/admin.json'.The setup project pattern (recommended for larger suites)
The globalSetup hook works, but it has one drawback: it runs outside Playwright's project and reporter system. Failures in global-setup.ts produce minimal output, and the setup doesn't appear in your HTML report.
The recommended alternative, introduced in Playwright 1.31, is a dedicated setup project. It runs before other projects and benefits from the full reporting pipeline.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://lab.becomeqa.com',
},
projects: [
// Setup project runs first, produces auth files
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Test projects depend on setup completing first
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['setup'],
},
],
});The setup file itself is an ordinary Playwright test file:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate as admin', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});Now the login step shows up in your HTML report, retry logic applies if the login page is flaky, and screenshots on failure are captured automatically.
Per-project storageState for multiple user roles
Most applications have more than one user role, and you need to test them independently. An admin user sees management controls. A regular user doesn't. If you run admin tests with a regular user's session, they'll fail for the wrong reason.
Add one setup step per role, one auth file per role, and one test project per role:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate as admin', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});
setup('authenticate as regular user', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('user@becomeqa.com');
await page.getByLabel('Password').fill('userpass456');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const ADMIN_AUTH = 'playwright/.auth/admin.json';
const USER_AUTH = 'playwright/.auth/user.json';
export default defineConfig({
use: {
baseURL: 'https://lab.becomeqa.com',
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Admin tests
{
name: 'admin-chromium',
testMatch: /.*admin.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: ADMIN_AUTH,
},
dependencies: ['setup'],
},
// Regular user tests
{
name: 'user-chromium',
testMatch: /.*user.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: USER_AUTH,
},
dependencies: ['setup'],
},
// Tests that don't need auth (landing page, login flow tests)
{
name: 'public',
testMatch: /.*public.*\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
],
});Each project loads a different auth file, and your admin tests never accidentally run with a regular user session.
public project and use the raw page fixture without any storageState. The whole point of those tests is to go through the login UI.Worker-scoped fixtures for storageState (advanced pattern)
There's a subtle problem with storageState in playwright.config.ts: it applies to the browser context. If a test does something that modifies authentication state, like updating the user's profile, changing their email, or (worst case) logging out, the modified context can bleed into the next test in the same worker.
The solution is to create a fresh context per test, loaded from the static auth file, rather than sharing one context across tests. A worker-scoped fixture handles this cleanly:
// fixtures/auth.fixture.ts
import { test as base, BrowserContext } from '@playwright/test';
import path from 'path';
const ADMIN_AUTH = path.resolve('playwright/.auth/admin.json');
type AuthFixtures = {
// Worker-scoped: the auth file path, loaded once per worker
adminStorageState: string;
};
type TestFixtures = {
// Test-scoped: a fresh context per test, loaded from the state file
adminContext: BrowserContext;
};
export const test = base.extend<TestFixtures, AuthFixtures>({
// Worker fixture just holds the path, validates the file exists once
adminStorageState: [
async ({}, use) => {
await use(ADMIN_AUTH);
},
{ scope: 'worker' },
],
// Test fixture creates a fresh context from the saved state
adminContext: async ({ browser, adminStorageState }, use) => {
const context = await browser.newContext({
storageState: adminStorageState,
});
await use(context);
await context.close();
},
});
export { expect } from '@playwright/test';Tests that use this fixture get an isolated browser context that starts authenticated, but any state changes within the test don't affect other tests:
// tests/admin-items.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
test('admin can see management panel', async ({ adminContext }) => {
const page = await adminContext.newPage();
await page.goto('/');
await expect(page.getByRole('link', { name: 'Admin Panel' })).toBeVisible();
});
test('admin can delete any item', async ({ adminContext }) => {
const page = await adminContext.newPage();
await page.goto('/items');
await page.getByTestId('item-row-1').getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId('item-row-1')).not.toBeVisible();
});Each test gets its own BrowserContext freshly initialized from the auth file. The deletion in the second test doesn't affect any shared state.
Combining storageState with API-based login (faster setup)
The auth.setup.ts file shown earlier does a full UI login: it navigates, clicks, fills forms, and waits. That works, but it's still several seconds. On a slow CI machine or when the login form has animations, it can be the bottleneck.
If your application has a login API endpoint, you can call it directly from the setup step, skip the UI entirely, and write the resulting token into storage state manually. This is typically 5–10x faster than the UI approach:
// tests/auth.setup.ts (API-based version)
import { test as setup, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const AUTH_FILE = 'playwright/.auth/admin.json';
setup('authenticate as admin via API', async ({ request }) => {
// Call the login endpoint directly
const response = await request.post('https://lab.becomeqa.com/api/auth/login', {
data: {
email: 'admin@becomeqa.com',
password: 'testpass123',
},
});
const { token, sessionCookie } = await response.json();
// Build the storageState structure manually
const storageState = {
cookies: [
{
name: 'session',
value: sessionCookie,
domain: 'lab.becomeqa.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 86400, // 24 hours
httpOnly: true,
secure: true,
sameSite: 'Lax' as const,
},
],
origins: [
{
origin: 'https://lab.becomeqa.com',
localStorage: [
{ name: 'auth_token', value: token },
],
},
],
};
// Ensure the directory exists
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
fs.writeFileSync(AUTH_FILE, JSON.stringify(storageState, null, 2));
});The trade-off: this approach requires you to know the exact shape of your application's auth storage (which cookies it sets, which localStorage keys it reads). The UI-based approach works regardless of the implementation details. You just log in and save whatever the browser ends up with. Start with the UI approach, switch to API-based if login becomes a measurable bottleneck.
When storageState breaks
storageState is not magic. It's a snapshot of browser state from a specific point in time. A few things will cause it to stop working:
Token expiry. If your application uses short-lived JWTs (15 minutes, 1 hour), the saved token will be expired by the time later tests run. The fix is either to regenerate the auth file at the start of every CI run (which you should be doing anyway), or to switch to API-based login that always issues a fresh token.
Server-side session invalidation. Some applications invalidate sessions when they detect anomalous patterns. Multiple simultaneous requests from the "same" session across different worker processes is one such pattern. If you see random 401 errors in tests that should be authenticated, check whether your application has session fixation protections that are treating parallel test workers as suspicious.
Two-factor authentication. 2FA breaks UI-based storageState setup entirely. The login flow requires a TOTP code or SMS verification that you can't automate through Playwright in a general way. The practical solutions are: use a dedicated test account with 2FA disabled (if your application allows it), use API-based login that issues tokens without 2FA for test environments, or add an environment variable that bypasses 2FA when NODE_ENV=test.
Browser-bound sessions. Some applications tie sessions to browser fingerprints, TLS client certificates, or device IDs. If your session cookies have attributes that restrict them to specific device characteristics, saving and restoring them across different browser instances won't work. This is uncommon in web applications but worth knowing.
// Verifying that saved state is still valid. Add this check to auth.setup.ts.
setup('authenticate as admin', async ({ page }) => {
// Try loading the existing state first
const AUTH_FILE = 'playwright/.auth/admin.json';
if (fs.existsSync(AUTH_FILE)) {
// Check if the existing token is still valid
const checkContext = await browser.newContext({ storageState: AUTH_FILE });
const checkPage = await checkContext.newPage();
await checkPage.goto('/');
const isAuthenticated = await checkPage.getByText('My Travel Items').isVisible();
await checkContext.close();
if (isAuthenticated) {
console.log('Existing auth state is valid, skipping login');
return; // Reuse the existing file
}
}
// State is invalid or missing, do the full login
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: AUTH_FILE });
});playwright/.auth/*.json contains real session tokens that grant access to your test accounts. Add the directory to .gitignore and rotate test account passwords regularly. If you're using environment variables for credentials (which you should be in CI), make sure those variables aren't logged in your pipeline output.storageState is the single highest-ROI change you can make to a slow Playwright suite. The setup is about 30 minutes of work and can cut your total test time by 20–30% on suites where most tests require authentication.
→ See also: Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Playwright Config File Explained: Every Option You Need to Know | API Testing with Playwright's APIRequestContext (No Postman Required) | Test Isolation: Why Each Playwright Test Should Be Stateless | Global Setup and Teardown in Playwright