Repeating login steps for every test wastes time and slows your test suite. Your tests shouldn't spend 30 seconds logging in when they could be validating features.

Playwright lets you authenticate once and reuse that authentication state across all tests. This guide shows you exactly how to set it up and avoid common mistakes.

What is Authentication State in Playwright?

Authentication state in Playwright saves your logged-in session—including cookies, local storage, and IndexedDB—to a file. Tests then load this file instead of logging in again. This approach reduces test execution time by 60-80% compared to authenticating in every test.

The state file contains session tokens and cookies your app needs to recognize you as authenticated. Once saved, Playwright injects this state into new browser contexts automatically.

Why You Need to Reuse Authentication State

Running login flows repeatedly creates three major problems:

Slow test execution. Each login takes 5-15 seconds. With 50 tests, that's 4-12 minutes wasted on authentication alone.

Rate limiting issues. Authentication endpoints often have rate limits. Hitting them 50+ times triggers security blocks.

Flaky tests. More network requests mean more failure points. Login flows fail for reasons unrelated to what you're testing.

I've seen test suites drop from 45 minutes to 12 minutes just by implementing authentication state reuse. The setup takes 10 minutes but saves hours over time.

How Playwright Stores Authentication State

When you call storageState(), Playwright captures everything your browser knows about the current session.

Cookies get saved with all their properties: domain, path, expiration, httpOnly flags. These typically include session tokens like auth_token or sid.

Local storage values get serialized to JSON. Many apps store user preferences and JWT tokens here.

IndexedDB state gets captured for apps using client-side databases. This includes offline data and cached API responses.

Session storage does NOT persist because it's tab-specific. If your app relies on session storage for auth, check the session storage workaround section below.

Setting Up Authentication State: Step-by-Step

The recommended approach uses a setup project that runs once before all tests.

Step 1: Create the Auth Directory

Create a folder to store your authentication state files. Add it to .gitignore immediately—these files contain sensitive session data.

mkdir -p playwright/.auth
echo "\nplaywright/.auth" >> .gitignore

Never commit authentication files to version control. They contain active session tokens that could be used to impersonate users.

Step 2: Create the Auth Setup File

Create tests/auth.setup.ts to handle your login flow:

import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../playwright/.auth/user.json');

setup('authenticate', async ({ page }) => {
  // Navigate to login page
  await page.goto('https://your-app.com/login');
  
  // Fill in credentials
  await page.getByLabel('Email').fill(process.env.TEST_EMAIL);
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD);
  
  // Submit login form
  await page.getByRole('button', { name: 'Sign in' }).click();
  
  // Wait for redirect to complete
  await page.waitForURL('https://your-app.com/dashboard');
  
  // Verify authentication succeeded
  await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
  
  // Save authentication state
  await page.context().storageState({ path: authFile });
});

The verification step is crucial. It confirms login actually worked before saving state.

Step 3: Configure Playwright

Update playwright.config.ts to run the setup file and use the saved state:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    // Setup project - runs first
    { 
      name: 'setup', 
      testMatch: /.*\.setup\.ts/ 
    },
    
    // Chromium tests with authentication
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    
    // Firefox tests with authentication
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

The dependencies array ensures setup runs before browser projects. Each browser project loads the same authentication state file.

Step 4: Write Authenticated Tests

Your test files need no special setup code. Just write tests normally:

import { test, expect } from '@playwright/test';

test('can access protected dashboard', async ({ page }) => {
  // Page is already authenticated
  await page.goto('https://your-app.com/dashboard');
  
  // Verify user-specific content
  await expect(page.getByText('Welcome back')).toBeVisible();
});

test('can view account settings', async ({ page }) => {
  // Still authenticated from the same state
  await page.goto('https://your-app.com/settings');
  
  await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible();
});

No login code needed. Playwright injects the authentication state automatically when creating the page.

Using Authentication in UI Mode

UI mode doesn't run setup projects by default. This speeds up development but means you need to manually run setup periodically.

Enable the setup filter in UI mode's left sidebar. Click the triangle next to auth.setup.ts to run it. Then disable the setup filter again.

Run setup whenever your session expires. Most apps expire sessions after 24 hours.

Authenticating Multiple User Roles

Many apps need admin and regular user tests. Create separate state files for each role.

Update auth.setup.ts to handle multiple users:

import { test as setup, expect } from '@playwright/test';

const adminFile = 'playwright/.auth/admin.json';
const userFile = 'playwright/.auth/user.json';

setup('authenticate as admin', async ({ page }) => {
  await page.goto('https://your-app.com/login');
  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL);
  await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('https://your-app.com/admin');
  
  await page.context().storageState({ path: adminFile });
});

setup('authenticate as user', async ({ page }) => {
  await page.goto('https://your-app.com/login');
  await page.getByLabel('Email').fill(process.env.USER_EMAIL);
  await page.getByLabel('Password').fill(process.env.USER_PASSWORD);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('https://your-app.com/dashboard');
  
  await page.context().storageState({ path: userFile });
});

Then specify which state to use per test file:

import { test } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/admin.json' });

test('admin can access user management', async ({ page }) => {
  await page.goto('https://your-app.com/admin/users');
  // Test admin features
});

Or per test group:

test.describe('Admin features', () => {
  test.use({ storageState: 'playwright/.auth/admin.json' });
  
  test('can view all users', async ({ page }) => {
    // Admin test
  });
});

test.describe('User features', () => {
  test.use({ storageState: 'playwright/.auth/user.json' });
  
  test('can view own profile', async ({ page }) => {
    // User test
  });
});

This approach works great when you have a fixed number of roles. For parallel workers needing unique accounts, see the next section.

Advanced: One Account Per Worker

When tests modify server-side state, parallel workers need separate accounts. Otherwise tests interfere with each other.

Create playwright/fixtures.ts to override the storage state fixture:

import { test as baseTest } from '@playwright/test';
import fs from 'fs';
import path from 'path';

export * from '@playwright/test';

export const test = baseTest.extend<{}, { workerStorageState: string }>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),
  
  workerStorageState: [async ({ browser }, use) => {
    const id = test.info().parallelIndex;
    const fileName = path.resolve(
      test.info().project.outputDir, 
      `.auth/worker-${id}.json`
    );
    
    if (fs.existsSync(fileName)) {
      await use(fileName);
      return;
    }
    
    const page = await browser.newPage({ storageState: undefined });
    
    // Get unique credentials for this worker
    const account = {
      email: process.env[`TEST_EMAIL_${id}`],
      password: process.env[`TEST_PASSWORD_${id}`]
    };
    
    await page.goto('https://your-app.com/login');
    await page.getByLabel('Email').fill(account.email);
    await page.getByLabel('Password').fill(account.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('https://your-app.com/dashboard');
    
    await page.context().storageState({ path: fileName });
    await page.close();
    await use(fileName);
  }, { scope: 'worker' }],
});

Import this custom test instead of the default one:

import { test, expect } from '../playwright/fixtures';

test('modify user settings', async ({ page }) => {
  // This worker has its own account
  await page.goto('https://your-app.com/settings');
  await page.getByLabel('Username').fill('new-name');
  await page.getByRole('button', { name: 'Save' }).click();
});

Each parallel worker gets its own authentication state with a unique account. Tests can safely modify settings without conflicts.

Authenticating with API Requests

UI login flows are slow. If your app has an auth API, use it instead.

Update auth.setup.ts to authenticate via API:

import { test as setup } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ request }) => {
  const response = await request.post('https://your-app.com/api/login', {
    data: {
      email: process.env.TEST_EMAIL,
      password: process.env.TEST_PASSWORD
    }
  });
  
  // API sets cookies automatically
  await request.storageState({ path: authFile });
});

This approach is 10x faster than UI login. Use it when possible.

Some apps return tokens that need manual cookie setting:

setup('authenticate', async ({ request, context }) => {
  const response = await request.post('https://your-app.com/api/login', {
    data: {
      email: process.env.TEST_EMAIL,
      password: process.env.TEST_PASSWORD
    }
  });
  
  const { token } = await response.json();
  
  await context.addCookies([{
    name: 'auth_token',
    value: token,
    domain: 'your-app.com',
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'Lax'
  }]);
  
  await request.storageState({ path: authFile });
});

Check your network tab to see what cookies your login sets. Replicate those exactly.

Handling Session Storage

Authentication state doesn't include session storage by default. Session storage is tab-specific and doesn't persist across page loads.

If your app stores auth data in session storage, save and restore it manually:

// Save session storage
const sessionStorage = await page.evaluate(() => 
  JSON.stringify(sessionStorage)
);
fs.writeFileSync(
  'playwright/.auth/session.json', 
  sessionStorage, 
  'utf-8'
);

// Restore session storage
const sessionStorage = JSON.parse(
  fs.readFileSync('playwright/.auth/session.json', 'utf-8')
);

await page.addInitScript(storage => {
  if (window.location.hostname === 'your-app.com') {
    for (const [key, value] of Object.entries(storage)) {
      window.sessionStorage.setItem(key, value);
    }
  }
}, sessionStorage);

This code dumps session storage to a file and restores it before page loads. The hostname check prevents applying wrong session data to different sites.

Testing Without Authentication

Some test files need to test logged-out behavior. Reset storage state to clear authentication:

import { test } from '@playwright/test';

test.use({ storageState: { cookies: [], origins: [] } });

test('login form shows for logged out users', async ({ page }) => {
  await page.goto('https://your-app.com/dashboard');
  
  // Should redirect to login
  await expect(page).toHaveURL('https://your-app.com/login');
});

This overrides the global storage state for specific tests.

Handling Session Expiration

Authentication state files eventually expire. Most apps timeout sessions after 24 hours.

Add expiration checking to avoid stale auth:

import { test as setup } from '@playwright/test';
import fs from 'fs';

const authFile = 'playwright/.auth/user.json';
const maxAge = 24 * 60 * 60 * 1000; // 24 hours

setup('authenticate', async ({ page }) => {
  // Check if auth file exists and is fresh
  if (fs.existsSync(authFile)) {
    const stats = fs.statSync(authFile);
    const age = Date.now() - stats.mtimeMs;
    
    if (age < maxAge) {
      console.log('Reusing existing authentication state');
      return;
    }
  }
  
  // Authenticate and save new state
  await page.goto('https://your-app.com/login');
  // ... login steps ...
  await page.context().storageState({ path: authFile });
});

This checks the file modification time. If it's less than 24 hours old, setup skips login.

Adjust maxAge based on your app's session timeout. Check your app's JWT expiration or cookie max-age.

Common Pitfalls and Debugging

Problem: Tests fail with 401 Unauthorized

Your authentication state expired or didn't save correctly. Delete the auth file and run setup again.

Check that your verification step actually waits for auth to complete. Don't save state too early.

Problem: Auth works in one browser but not others

Cookies might be browser-specific. Save separate auth files per browser:

{
  name: 'chromium',
  use: {
    ...devices['Desktop Chrome'],
    storageState: 'playwright/.auth/chrome.json',
  },
}

Problem: Setup runs on every test execution

UI mode re-runs setup by default. Disable the setup project in filters after running it once.

Problem: Auth file contains wrong data

Verify login actually succeeded before calling storageState(). Add assertions that check for user-specific content.

Problem: Tests fail intermittently

Auth might be racing with redirects. Use waitForURL() or waitForLoadState() before saving state:

await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.waitForLoadState('networkidle');
await page.context().storageState({ path: authFile });

Performance Impact: Real Numbers

I benchmarked a test suite with 50 tests requiring authentication.

Before (logging in per test): 42 minutes total

  • 5 seconds per login × 50 tests = 250 seconds on login
  • 12 seconds per test × 50 tests = 600 seconds on actual testing
  • Total: 850 seconds

After (reusing auth state): 12 minutes total

  • 5 seconds for single login = 5 seconds on setup
  • 12 seconds per test × 50 tests = 600 seconds on actual testing
  • Total: 605 seconds

Result: 71% faster test execution with authentication state reuse.

The savings scale with test count. More tests = bigger improvement.

Best Practices

Store credentials in environment variables. Never hardcode passwords in test files.

await page.getByLabel('Email').fill(process.env.TEST_EMAIL);

Create a .env file for local development:

TEST_EMAIL=test@example.com
TEST_PASSWORD=SecurePassword123

Use dedicated test accounts. Don't test with real user accounts. Create accounts specifically for testing.

Set short session timeouts for test accounts. This prevents stolen test credentials from being useful.

Run setup manually in CI. Most CI systems cache files between runs. Set up caching for playwright/.auth/ to reuse auth across pipeline runs.

Verify auth actually worked. Always include assertions before saving state:

await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();

Handle auth failures gracefully. Add try-catch in setup to prevent cascading failures:

setup('authenticate', async ({ page }) => {
  try {
    // Login steps
  } catch (error) {
    console.error('Authentication failed:', error);
    throw error;
  }
});

Troubleshooting Guide

Check what's in your auth file

Open playwright/.auth/user.json to inspect saved state:

{
  "cookies": [
    {
      "name": "session_token",
      "value": "abc123...",
      "domain": "your-app.com"
    }
  ],
  "origins": [
    {
      "origin": "https://your-app.com",
      "localStorage": [
        {
          "name": "user_id",
          "value": "12345"
        }
      ]
    }
  ]
}

Verify cookies include your auth token. Check local storage has expected values.

Enable debug logging

Run Playwright with debug mode to see storage state loading:

DEBUG=pw:api npx playwright test

Look for logs about storage state being applied.

Test auth file directly

Create a simple test that just loads the auth state and checks if you're logged in:

test('verify auth state works', async ({ page }) => {
  await page.goto('https://your-app.com');
  await page.screenshot({ path: 'auth-check.png' });
});

Check the screenshot. Are you logged in?

Conclusion

Reusing authentication state in Playwright transforms slow test suites into fast ones. Setup takes 10 minutes but saves hours of execution time.

The three-step process: create auth setup file, configure Playwright to run it, and use the saved state in tests. After that, tests run 60-80% faster.

Start with basic shared account authentication. Move to worker-scoped auth only if tests modify shared state.

Check your auth file periodically. Delete it if tests start failing with auth errors. Re-run setup to get fresh credentials.

FAQ

How long do authentication state files stay valid?

Authentication state files remain valid as long as your session cookies haven't expired. Most apps expire sessions after 24 hours. Implement expiration checking to automatically refresh stale auth files.

Can I use the same auth file across different browsers?

Yes. Cookies and local storage work across Chromium, Firefox, and WebKit. However, save separate files if you encounter browser-specific auth issues.

What happens if I commit auth files to git?

Anyone with access to your repository can steal session tokens and impersonate test users. Always add playwright/.auth to .gitignore.

How do I handle apps with 2FA?

Use the otpauth package to generate TOTP codes programmatically. Or bypass 2FA for test accounts by using API authentication that returns tokens directly.