> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Maintain Authentication State in Playwright
- URL: https://roundproxies.com/blog/authentication-playwright/
- Published: 2025-11-17T11:30:52.000Z
- Updated: 2026-09-23T13:04:39.000Z
- Description: Learn how to reuse authentication state in Playwright to skip login flows, speed up tests by 70%, reduce flakiness, and support multiple user roles.
- Author: Marius Bernard
- Tags: Playwright, #dated-6918d0f0358d710426c14cc7

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.

```bash
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
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:

```typescript
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:

```typescript
{
  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:

```typescript
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.

```typescript
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:

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

```

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

```typescript
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:

```json
{
  "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:

```bash
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:

```typescript
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.