Auth, Storage State, and Multi-User Flows

Authentication in tests is deceptively hard. Learn how to use Playwright's storageState to log in once and reuse sessions, support multi-role testing, and avoid the most common security and.

This post is part of the Playwright Essentials series. If you missed the previous post, read Part 5 — Locators and Assertions first.


Introduction — Authentication Is Not a Test Step

The most common anti-pattern in end-to-end test automation is treating authentication as part of the test itself. Every test logs in at the start, fills the login form, submits it, waits for navigation, and then begins the actual test scenario.

This approach has three problems:

  1. It wastes time — A login flow that takes 2 seconds per test means a 100-test suite spends over 3 minutes just logging in.
  2. It increases flakiness — Login involves network requests, redirects, third-party identity providers, and rate limiting. More steps mean more opportunities for transient failures.
  3. It misses the point — Authentication is infrastructure. Your test should focus on the behaviour you’re testing, not repeatedly proving that the login form works.

Playwright’s storageState API solves this problem. You log in once, save the session state (cookies, localStorage, sessionStorage), and reuse it across all tests. Tests become faster, more reliable, and more focused.

This post will show you how to use storageState correctly, how to support multiple user roles in parallel, and how to avoid the most common security and parallelization pitfalls.


The Problem: Every Test Logs In

Here’s what naive authentication looks like:

test('user can view dashboard', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await page.waitForURL('**/dashboard');

  // Now the test actually starts
  await expect(page.locator('h1')).toHaveText('Dashboard');
});

test('user can edit profile', async ({ page }) => {
  // Repeat the entire login flow again
  await page.goto('https://example.com/login');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await page.waitForURL('**/dashboard');

  // Now the test actually starts
  await page.goto('/settings/profile');
  await expect(page.locator('input[name="displayName"]')).toBeVisible();
});

If you have 50 tests, you’ve just executed the login flow 50 times. That’s 50 network round trips, 50 database queries, 50 session token generations. It’s slow and wasteful.


The Solution: Global Setup and Storage State

Playwright lets you log in once in a global setup script, save the authenticated session state to a file, and reuse it in all tests.

Step 1: Create a Global Setup Script

Create global-setup.ts in your project root:

// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Perform login
  await page.goto('https://example.com/login');
  await page.fill('input[name="email"]', process.env.TEST_USER_EMAIL!);
  await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD!);
  await page.click('button[type="submit"]');
  await page.waitForURL('**/dashboard');

  // Save the authenticated state
  await page.context().storageState({ path: 'auth/user.json' });

  await browser.close();
}

export default globalSetup;

:::warning[Never Hardcode Credentials] Always read credentials from environment variables, never commit them to source control. Use .env files locally and CI secrets in pipelines. :::

Step 2: Configure Playwright to Use the Setup

Update playwright.config.ts:

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

export default defineConfig({
  globalSetup: './global-setup.ts',
  use: {
    storageState: 'auth/user.json',
  },
  // ... rest of config
});

Step 3: Write Tests Without Authentication Logic

Now every test starts already authenticated:

test('user can view dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.locator('h1')).toHaveText('Dashboard');
});

test('user can edit profile', async ({ page }) => {
  await page.goto('/settings/profile');
  await expect(page.locator('input[name="displayName"]')).toBeVisible();
});

The login step is gone. Tests are faster, simpler, and more focused.


Multi-User Testing: Projects for Different Roles

Most real applications have multiple user roles: admin, regular user, guest. You need to test workflows for each role, which means you need multiple authenticated sessions.

Playwright’s projects feature lets you define multiple configurations, each with its own storageState.

Step 1: Extend Global Setup to Support Multiple Users

// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
import * as fs from 'fs';

async function globalSetup(config: FullConfig) {
  const users = [
    {
      email: process.env.TEST_USER_EMAIL!,
      password: process.env.TEST_USER_PASSWORD!,
      storageStatePath: 'auth/user.json',
    },
    {
      email: process.env.TEST_ADMIN_EMAIL!,
      password: process.env.TEST_ADMIN_PASSWORD!,
      storageStatePath: 'auth/admin.json',
    },
  ];

  // Ensure auth directory exists
  if (!fs.existsSync('auth')) {
    fs.mkdirSync('auth');
  }

  const browser = await chromium.launch();

  for (const user of users) {
    const page = await browser.newPage();
    await page.goto('https://example.com/login');
    await page.fill('input[name="email"]', user.email);
    await page.fill('input[name="password"]', user.password);
    await page.click('button[type="submit"]');
    await page.waitForURL('**/dashboard');
    await page.context().storageState({ path: user.storageStatePath });
    await page.close();
  }

  await browser.close();
}

export default globalSetup;

Step 2: Define Projects in playwright.config.ts

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

export default defineConfig({
  globalSetup: './global-setup.ts',
  projects: [
    {
      name: 'user',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'auth/user.json',
      },
    },
    {
      name: 'admin',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'auth/admin.json',
      },
    },
  ],
});

Step 3: Write Role-Specific Tests

// user.spec.ts
import { test, expect } from '@playwright/test';

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

test('regular user cannot access admin panel', async ({ page }) => {
  await page.goto('/admin');
  await expect(page.locator('text=Access Denied')).toBeVisible();
});

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

test('admin can access admin panel', async ({ page }) => {
  await page.goto('/admin');
  await expect(page.locator('h1')).toHaveText('Admin Panel');
});

Now you can run tests for specific roles:

npx playwright test --project=user
npx playwright test --project=admin
npx playwright test  # runs all projects

Parallel Execution and Shared State

When running tests in parallel (which Playwright does by default), every worker process gets its own isolated browser context. This means:

  • ✅ Each worker can use the same storageState file without conflict
  • ✅ Tests don’t interfere with each other’s sessions
  • ⚠️ But if your tests modify the account (e.g., change email, delete profile), those changes are not isolated

The Problem: Shared Account Mutation

test('user can change email', async ({ page }) => {
  await page.goto('/settings/profile');
  await page.fill('input[name="email"]', 'newemail@example.com');
  await page.click('button[type="submit"]');
  await expect(page.locator('text=Email updated')).toBeVisible();
});

test('user can view current email', async ({ page }) => {
  await page.goto('/settings/profile');
  // This test might now see 'newemail@example.com' or 'user@example.com'
  // depending on execution order
  await expect(page.locator('input[name="email"]')).toHaveValue('user@example.com');
});

If these tests run in parallel, the second test may fail because the first test changed the email.

The Solution: Use Separate Accounts or Fixtures

Option 1: Test accounts per worker

Create multiple test accounts (user1, user2, user3) and assign one per worker. Playwright’s workerIndex can help:

test.use({
  storageState: async ({}, use, testInfo) => {
    const workerIndex = testInfo.parallelIndex;
    await use(`auth/user-${workerIndex}.json`);
  },
});

Option 2: Reset state in fixtures

If your app has an API to reset test account state, wrap it in a fixture:

test.beforeEach(async ({ request }) => {
  await request.post('/api/test/reset-account', {
    data: { email: process.env.TEST_USER_EMAIL },
  });
});

Option 3: Don’t mutate shared state

Design tests so they don’t modify the account in ways that affect other tests. For example, instead of changing the account email, test that the form validation works without actually submitting.


Common Pitfalls

Pitfall 1: Committing auth/*.json to Git

The storageState JSON file contains session cookies. If you commit it to Git, anyone with repository access can impersonate that user.

Solution: Add auth/ to .gitignore:

# .gitignore
auth/
*.storage.json

Generate storageState files in CI as part of the test setup, never commit them.

Pitfall 2: Expired Sessions

If your authentication tokens expire after 24 hours, and your CI runs daily, the storageState from yesterday is now invalid.

Solution: Regenerate storageState in globalSetup on every test run. This is the default behaviour if you follow the setup above.

Pitfall 3: Testing the Login Flow Itself

If you use storageState for all tests, how do you test that the login flow works?

Solution: Write one dedicated test spec without storageState:

// auth.spec.ts
import { test, expect } from '@playwright/test';

test.use({ storageState: undefined }); // Opt out of global storageState

test('user can log in', async ({ page }) => {
  await page.goto('/login');
  await page.fill('input[name="email"]', process.env.TEST_USER_EMAIL!);
  await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD!);
  await page.click('button[type="submit"]');
  await page.waitForURL('**/dashboard');
  await expect(page.locator('h1')).toHaveText('Dashboard');
});

test('login fails with invalid credentials', async ({ page }) => {
  await page.goto('/login');
  await page.fill('input[name="email"]', 'wrong@example.com');
  await page.fill('input[name="password"]', 'wrongpassword');
  await page.click('button[type="submit"]');
  await expect(page.locator('text=Invalid credentials')).toBeVisible();
});

This spec runs without storageState, so it tests the login flow from scratch.


When Not to Use Storage State

storageState is not always the right choice. Use it when:

  • ✅ Most tests require authentication
  • ✅ Authentication is slow (SAML, OAuth, MFA)
  • ✅ Tests are independent and don’t modify the authenticated user

Don’t use storageState when:

  • ❌ Testing the authentication flow itself
  • ❌ Testing account registration
  • ❌ Testing password reset or email verification flows
  • ❌ Each test requires a unique user (e.g., testing user-to-user interactions)

For those cases, authenticate inline as part of the test.


Conclusion

Authentication is infrastructure, not test logic. Playwright’s storageState lets you log in once, save the session, and reuse it across hundreds of tests. This makes tests faster, more reliable, and easier to maintain.

Key takeaways:

  • Use globalSetup to authenticate once and save storageState
  • Use projects to support multiple user roles
  • Never commit storageState files to source control
  • Be careful with parallel execution if tests mutate shared account state
  • Write dedicated tests for the authentication flow itself without storageState

:::tip[Design for Parallelism] When designing your test suite, assume tests will run in parallel. If a test modifies account state that other tests depend on, either isolate it with .serial() or give each worker its own test account. :::

Action for this week: Refactor your test suite to use storageState. Measure how much time it saves on a full test run. If you have multiple user roles, add projects for each role and verify that role-specific tests run correctly.


Next in this series: Part 7 — Visual and Accessibility Checks in Playwright