Playwright

How to Maintain Authentication State in Playwright

Repeating the login flow in every test is usually the slowest thing a Playwright suite does. Fill the username, fill the password, click submit, wait for the redirect: 30 seconds per test that could have gone to checking the feature you actually care about. You only need to log in once. Playwright can save login state to a JSON file and hand it to every browser context that runs afterwards.

The mechanism is storageState. After a successful login, Playwright serializes the cookies and localStorage that the app used to mark you as signed in, writes them to disk, and replays them into fresh contexts. Tests still run isolated from each other, they just start on the far side of the login screen.

That covers the happy path. The parts that bite later are the ones nobody warns you about: state files that expire mid-run, tests that mutate server-side data and poison the shared account, apps that keep their token in sessionStorage where storageState never looks, and credentials accidentally committed to git.

This guide covers the setup project that generates the state file, how to wire it into playwright.config.ts, and when a single shared account stops being enough. My default is one account for the whole suite, split into per-worker accounts only once tests start stepping on each other. Starting with per-worker isolation is work you probably don't need yet.

What is authentication state in Playwright?

Authentication state is the bundle of cookies, localStorage entries, and IndexedDB records a site uses to recognize a signed-in user. Playwright serializes that bundle into a single JSON file, and any browser context created afterwards can start from it already logged in. That is the whole idea behind using Playwright to save login state: drive the login form once, write the session to disk, then bootstrap every test from that file. Skipping the login flow reduces test execution time by 60-80% compared to authenticating in every test.

The reason you need this at all is isolation. Every Playwright test runs in a fresh browser context with an empty cookie jar and empty storage, which is what keeps one failing test from poisoning the next. The cost of that guarantee is that a logged-in session does not survive from one test to the next unless you hand it over deliberately.

What ends up in the state file

The saved JSON has two halves: a list of cookies with their domain, path, and expiry flags, and a per-origin list of localStorage keys and values. If your app keeps a JWT in a cookie or in localStorage, it is in there. Playwright injects the whole thing into the new context before the first navigation, so the first request already carries the session.

What is not in there matters just as much. sessionStorage is tied to a tab and does not get persisted. Tokens that live only in JavaScript memory, held by a Redux store or an auth SDK that re-fetches on boot, are gone too. If your app looks authenticated in a real browser but not in a restored context, one of those two is usually the reason.

Where it stops working

The file is a snapshot, not a live session. Once the tokens inside it expire, every test that loads it fails at the same moment, and the error usually looks like a redirect to the login page rather than an auth error. Refreshing state on a schedule is part of the setup, not an afterthought.

Two more limits are worth knowing before you build on this. Tests that mutate account-level server state, one changing a setting while another asserts on the settings page, will fight each other if they share one account and run in parallel. And if authentication differs per browser, you need a state file per browser rather than one shared file. My default is a single shared state file for the whole suite, split only when tests actually write to the account.

Treat the file as a live credential. It contains cookies that let anyone replay your session, so keep it in a playwright/.auth directory and add that directory to .gitignore before you generate the first one. Committing it to a repo, private or not, hands over the test account.

Why you need to reuse authentication state

Playwright runs every test in a fresh browser context. Fresh context means no cookies, no localStorage, no session. So unless you tell it otherwise, test 1 logs in, test 2 logs in again, and test 50 logs in for the fiftieth time. That isolation is the right default for reproducibility, but paying for it on every test is a choice, and it's usually the wrong one. Playwright can save login state to a JSON file once and hand it to every context that follows.

Three things go wrong when you don't.

The clock

A login flow takes 5-15 seconds: page load, form fill, submit, redirect, wait for the dashboard to settle. Across 50 tests that's 4 to 12 minutes spent typing a password nobody is testing. Parallel workers don't remove the cost, they just spread it, and each worker still pays it once per context it creates.

The auth endpoint fights back

Login endpoints are the most defended route in most applications. Rate limits, temporary account lockouts, CAPTCHA challenges after repeated attempts, MFA prompts, and "new device" email confirmations all trigger on exactly the pattern a test suite produces: the same account hammering /login from CI. The suite that passed yesterday starts failing today because security tooling decided your test account looked like an attacker. Reusing stored state means one authentication per run instead of one per test.

Flakiness you didn't sign up for

Every login adds network requests, redirects, and waits before the test does anything useful. Each of those is a place to fail. When a checkout test dies on a slow SSO redirect, you spend an hour debugging checkout and find nothing wrong with it. Cutting the login out of the test body removes an entire class of failures that has nothing to do with the assertions you care about.

I've seen a suite go from 45 minutes to 12 just by loading saved authentication state instead of logging in per test. Setup takes about 10 minutes.

One caveat before you apply this everywhere: a single shared state file works when tests read but don't mutate account-level server state. If one test changes a setting while another asserts on how that settings page renders, running them in parallel against the same account will produce failures that look random. Same problem with role-based apps: an admin test and a read-only-user test need separate state files. Both cases are solvable, and later sections cover per-role state, but decide which situation you're in before you standardise on one file.

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 matters. 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 works when the set of roles is known ahead of time. 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 time out 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.

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:

[email protected]
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 cuts login overhead from every test to one setup step. In the benchmark above, that dropped a 50-test run from 42 minutes to 12.

The steps: create an auth setup file, configure Playwright to run it first, and load the saved state in every test project.

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.