Playwright Auth State Reuse: Save Login Once, Run Tests Fast
Authentication is one of the biggest time sinks in end-to-end test suites. If every test logs in before doing its actual work, you're spending a significant portion of your test runtime on a flow you've already validated hundreds of times. Playwright's storage state API solves this by letting you save the browser's authenticated state — cookies, localStorage, and session storage — and reuse it across tests.
The Problem with Logging In Every Test
A typical login flow takes 2–5 seconds: navigate to the login page, fill credentials, submit, wait for redirect, verify authentication. Multiply that by 100 tests and you've added 3–8 minutes to your suite just for authentication setup that has nothing to do with what the tests are actually verifying.
Beyond speed, repeated logins increase flakiness. Any instability in the login flow propagates as failures across your entire suite, even when the features being tested work perfectly.
How Storage State Works
Playwright can capture the full browser context state — cookies, localStorage, sessionStorage — at any point and save it to a JSON file. On subsequent test runs, you restore this saved state instead of going through the login flow again.
The saved state includes everything the browser stores to maintain authentication: session cookies, JWT tokens in localStorage, refresh tokens, user preference data. When restored, the browser behaves exactly as if the user had just logged in.
Setting Up a Global Authentication Script
The recommended approach is a global setup script that runs once before any tests, logs in, and saves the state:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'playwright/.auth/user.json',
},
});// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://your-app.com/login');
await page.fill('[data-testid="email"]', process.env.TEST_USER_EMAIL!);
await page.fill('[data-testid="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('[data-testid="login-button"]');
await page.waitForURL('**/dashboard');
// Save the authenticated state
await page.context().storageState({ path: 'playwright/.auth/user.json' });
await browser.close();
}
export default globalSetup;Now every test runs with the saved authenticated state automatically — no login needed. The storageState option in use tells Playwright to restore this state for every test context.
Multiple User Roles
Most applications have multiple user types — admin, regular user, read-only viewer. Save separate state files for each role:
// global-setup.ts
import { chromium } from '@playwright/test';
import path from 'path';
async function globalSetup() {
const browser = await chromium.launch();
// Admin user
const adminPage = await browser.newPage();
await loginAs(adminPage, process.env.ADMIN_EMAIL!, process.env.ADMIN_PASSWORD!);
await adminPage.context().storageState({
path: 'playwright/.auth/admin.json'
});
await adminPage.close();
// Regular user
const userPage = await browser.newPage();
await loginAs(userPage, process.env.USER_EMAIL!, process.env.USER_PASSWORD!);
await userPage.context().storageState({
path: 'playwright/.auth/user.json'
});
await userPage.close();
await browser.close();
}
async function loginAs(page: any, email: string, password: string) {
await page.goto('https://your-app.com/login');
await page.fill('[name="email"]', email);
await page.fill('[name="password"]', password);
await page.click('[type="submit"]');
await page.waitForURL('**/dashboard');
}
export default globalSetup;Reference the appropriate state file per test file or describe block:
// admin.spec.ts — uses admin auth state
import { test } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/admin.json' });
test('admin can delete users', async ({ page }) => {
await page.goto('/admin/users');
// Already authenticated as admin
});// user.spec.ts — uses regular user auth state
import { test } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/user.json' });
test('user cannot access admin panel', async ({ page }) => {
await page.goto('/admin');
await expect(page).toHaveURL('/403');
});Using Projects for Role-Based Testing
Playwright's projects feature makes role management cleaner by defining authentication at the project level:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global-setup\.ts/,
},
{
name: 'admin tests',
use: { storageState: 'playwright/.auth/admin.json' },
dependencies: ['setup'],
},
{
name: 'user tests',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});The dependencies: ['setup'] ensures the setup project runs first. Tests in each project automatically get the right auth state with no per-file configuration needed.
Scoped Auth Fixtures for Flexibility
When you need fine-grained control inside a single test file, create scoped fixtures:
// fixtures.ts
import { test as base, BrowserContext } from '@playwright/test';
interface AuthFixtures {
adminContext: BrowserContext;
userContext: BrowserContext;
}
export const test = base.extend<AuthFixtures>({
adminContext: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
await use(context);
await context.close();
},
userContext: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
await use(context);
await context.close();
},
});
// permissions.spec.ts
import { test } from './fixtures';
test('admin sees delete button, user does not', async ({ adminContext, userContext }) => {
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
await adminPage.goto('/documents/123');
await userPage.goto('/documents/123');
await expect(adminPage.locator('[data-testid="delete-button"]')).toBeVisible();
await expect(userPage.locator('[data-testid="delete-button"]')).not.toBeVisible();
});This pattern lets a single test run as two different users simultaneously — useful for testing role-based access control.
Handling Token Expiry
Saved auth states expire when session cookies expire or tokens rotate. Handle this gracefully:
// global-setup.ts
import { chromium } from '@playwright/test';
import fs from 'fs';
const AUTH_FILE = 'playwright/.auth/user.json';
async function globalSetup() {
// Check if saved state is recent enough (less than 1 hour old)
if (fs.existsSync(AUTH_FILE)) {
const stat = fs.statSync(AUTH_FILE);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs < 60 * 60 * 1000) {
console.log('Using cached auth state');
return; // Skip login, reuse cached state
}
}
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://your-app.com/login');
await page.fill('[name="email"]', process.env.TEST_USER_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('[type="submit"]');
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: AUTH_FILE });
await browser.close();
}
export default globalSetup;For apps with very short-lived tokens, you may need to refresh tokens rather than re-login. Check your app's token refresh endpoint and call it directly via the API.
Testing Auth Itself
One test in your suite should still test the actual login flow — don't mock or skip the auth UI entirely:
// auth.spec.ts — explicitly does NOT use saved state
import { test, expect } from '@playwright/test';
test.use({ storageState: { cookies: [], origins: [] } }); // Empty state
test('successful login redirects to dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', process.env.TEST_USER_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('[type="submit"]');
await expect(page).toHaveURL(/dashboard/);
});
test('invalid credentials show error message', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'wrong@example.com');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('[type="submit"]');
await expect(page.locator('[data-testid="login-error"]')).toContainText('Invalid credentials');
});These tests run against a fresh browser with no saved state, ensuring the login flow itself stays tested.
CI/CD Considerations
Don't commit auth state files. They contain live session tokens. Add them to .gitignore:
playwright/.auth/Regenerate in CI every run. The global setup runs before tests in CI, generating fresh auth state on every pipeline run. This is fast — login takes 2–5 seconds — and avoids stale state issues.
Use environment variables for credentials. Never hardcode test user credentials. Store them as CI secrets and inject via environment variables.
Store in CI artifacts if rerunning failed tests. If your CI runs multiple retries or split test shards, save the auth state as an artifact shared across workers.
Measuring the Impact
Before and after implementing auth state reuse, measure your suite's total runtime. For a 100-test suite where each test previously spent 3 seconds on login:
- Before: 300 seconds of login time (5 minutes pure overhead)
- After: 3 seconds for one global setup login
- Savings: ~297 seconds (nearly 5 minutes)
For larger suites the gains compound. Auth state reuse is one of the highest-impact optimizations available with zero change to your actual test logic.
Playwright's storage state API is a small configuration change with dramatic results. Set it up once, and every test in your suite gets instant authenticated sessions — no repeated logins, no extra flakiness, no wasted time.