Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

How to Reuse Login Sessions for 5+ User Roles in Playwright (Without Logging In Every Test)

April 25, 2026 EST. READ: 13 MIN #Quality Assurance

Every Playwright project I inherit has the same anti-pattern: a beforeEach hook that logs in the user before each test. On a 200-test suite, that's 200 logins. At 4 seconds per login, that's 13 minutes of CI time spent typing passwords into forms. Multiply by every PR.

The official answer is storageState, and yes, the docs cover the basic case — one user, one auth file, reuse it across tests. The docs do not cover what happens when your app has five user roles, parallel workers, and tests that need fresh session state mid-run. That's the gap I'll close here.

I'll walk through the exact setup running on a current client project: admin, store manager, two customer tiers (free + premium), and a guest fixture. All running in parallel across 4 workers without flakiness. Total auth overhead: under 8 seconds for the entire suite.

Table of Contents

Why the Simple storageState Pattern Breaks at Scale

The textbook setup: write one global setup file that logs in, save storageState.json, configure use.storageState in playwright.config, every test uses that one logged-in user. Done.

Three things break this:

  1. Multiple roles. You can't put two users in one storage state file.
  2. Parallel workers. If two workers share the same admin session and one runs a destructive action, the other sees the result mid-test.
  3. Session storage. storageState persists cookies, localStorage, and IndexedDB. It does not persist sessionStorage. If your app keeps anything important in sessionStorage, it's gone the moment a new browser context opens.

Each problem has a known fix. Combining all three into one workflow is what trips teams up.

The Directory and File Structure I Use

tests/
├── auth/
│   ├── admin.json          # generated
│   ├── manager.json        # generated
│   ├── customer-free.json  # generated
│   ├── customer-premium.json # generated
│   └── .gitignore          # ignore *.json files
├── auth.setup.ts            # the setup project
├── fixtures/
│   └── session-storage.ts   # session storage workaround
├── admin/
│   └── *.spec.ts
├── manager/
│   └── *.spec.ts
└── customer/
    └── *.spec.ts

The .gitignore in auth/ is critical. Auth files contain real session tokens. They never get committed. If a teammate clones the repo, they regenerate them on first run.

The auth.setup.ts File (Logs In Once, Saves All Roles)

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const USERS = [
  {
    role: 'admin',
    email: process.env.E2E_ADMIN_EMAIL!,
    password: process.env.E2E_ADMIN_PASSWORD!,
    expectedUrl: '/admin/dashboard',
  },
  {
    role: 'manager',
    email: process.env.E2E_MANAGER_EMAIL!,
    password: process.env.E2E_MANAGER_PASSWORD!,
    expectedUrl: '/manage/store',
  },
  {
    role: 'customer-free',
    email: process.env.E2E_FREE_EMAIL!,
    password: process.env.E2E_FREE_PASSWORD!,
    expectedUrl: '/account',
  },
  {
    role: 'customer-premium',
    email: process.env.E2E_PREMIUM_EMAIL!,
    password: process.env.E2E_PREMIUM_PASSWORD!,
    expectedUrl: '/account?tier=premium',
  },
];

for (const user of USERS) {
  setup(`authenticate as ${user.role}`, async ({ page }) => {
    const authFile = path.join(__dirname, 'auth', `${user.role}.json`);

    await page.goto('/login');
    await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
    await page.getByRole('textbox', { name: 'Password' }).fill(user.password);
    await page.getByRole('button', { name: 'Sign in' }).click();

    await page.waitForURL(user.expectedUrl);
    await expect(page.getByTestId('user-menu')).toBeVisible();

    await page.context().storageState({ path: authFile });
  });
}

One setup file, four logins, four storage state files. Each setup() call gets a fresh browser context, so the logins don't interfere with each other.

For the guest user (no auth at all), I don't generate a file — guest tests just don't specify storageState.

Wiring Projects to Roles in playwright.config.ts

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,

  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'admin',
      testMatch: /admin\/.*\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: 'tests/auth/admin.json' },
    },
    {
      name: 'manager',
      testMatch: /manager\/.*\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: 'tests/auth/manager.json' },
    },
    {
      name: 'customer-free',
      testMatch: /customer\/free.*\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: 'tests/auth/customer-free.json' },
    },
    {
      name: 'customer-premium',
      testMatch: /customer\/premium.*\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: 'tests/auth/customer-premium.json' },
    },
    {
      name: 'guest',
      testMatch: /guest\/.*\.spec\.ts/,
      // No storageState - starts unauthenticated
    },
  ],
});

The dependencies: ['setup'] is the key. It guarantees the setup project runs before any test project. If a setup login fails, the dependent project is skipped — you get a clear error instead of 50 failing tests.

Using Roles in Your Tests

Tests in tests/admin/ automatically run as admin:

// tests/admin/users.spec.ts
import { test, expect } from '@playwright/test';

test('admin can delete a user', async ({ page }) => {
  await page.goto('/admin/users');
  await page.getByRole('row', { name: /jane@test.com/ })
    .getByRole('button', { name: 'Delete' })
    .click();
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('row', { name: /jane@test.com/ })).toHaveCount(0);
});

What if a single test needs to compare two roles? You spin up a second browser context inside the test:

// tests/admin/audit-trail.spec.ts
import { test, expect, chromium } from '@playwright/test';

test('admin sees audit log when manager makes a change', async ({ page, browser }) => {
  // Start as admin (default for this project)
  await page.goto('/admin/audit');
  const initialRowCount = await page.getByRole('row').count();

  // Spawn a manager context
  const managerContext = await browser.newContext({
    storageState: 'tests/auth/manager.json',
  });
  const managerPage = await managerContext.newPage();
  await managerPage.goto('/manage/store');
  await managerPage.getByRole('button', { name: 'Update prices' }).click();
  await managerContext.close();

  // Verify admin sees the audit entry
  await page.reload();
  await expect(page.getByRole('row')).toHaveCount(initialRowCount + 1);
});

Per-Worker Isolation for State-Mutating Tests

If your tests modify shared data — and most do — you'll hit conflicts. Worker 1 deletes user X, worker 2 expects user X to exist, both fail.

Two solutions, depending on how much you control:

Solution A: Per-worker users (cleanest)

Generate a unique user per worker at setup time:

// tests/auth.setup.ts (additions)
setup('authenticate per-worker customer', async ({ page }, testInfo) => {
  const workerIndex = testInfo.parallelIndex;
  const email = `e2e-customer-${workerIndex}@test.com`;
  // Create user via API (not UI - faster)
  await fetch(`${process.env.API_URL}/test/create-user`, {
    method: 'POST',
    headers: { 'X-Test-Key': process.env.E2E_TOKEN! },
    body: JSON.stringify({ email, password: 'password123' }),
  });
  // Then log in as that user
  await page.goto('/login');
  await page.getByRole('textbox', { name: 'Email' }).fill(email);
  await page.getByRole('textbox', { name: 'Password' }).fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.context().storageState({
    path: `tests/auth/customer-worker-${workerIndex}.json`,
  });
});

Solution B: Read-only roles for shared data

If you can't create per-worker users (rare, usually because of a third-party auth provider), partition your tests: state-mutating tests run with workers: 1, read-only tests run in parallel.

// playwright.config.ts
projects: [
  {
    name: 'admin-mutating',
    testMatch: /admin\/.*\.mutating\.spec\.ts/,
    fullyParallel: false,
    use: { storageState: 'tests/auth/admin.json' },
  },
  {
    name: 'admin-readonly',
    testMatch: /admin\/.*\.readonly\.spec\.ts/,
    fullyParallel: true,
    use: { storageState: 'tests/auth/admin.json' },
  },
]

The Session Storage Workaround Playwright Doesn't Document

If your app stores authentication tokens in sessionStorage (some SSO setups do this), storageState won't save them. Here's the workaround:

// tests/auth.setup.ts (modified for sessionStorage)
setup('authenticate with session storage', async ({ page }) => {
  await page.goto('/login');
  // ... login flow ...

  // Capture sessionStorage as JSON
  const sessionStorage = await page.evaluate(() =>
    JSON.stringify(sessionStorage)
  );

  // Save alongside storageState
  await page.context().storageState({ path: 'tests/auth/sso.json' });
  await fs.writeFile('tests/auth/sso-session.json', sessionStorage);
});

// fixtures/session-storage.ts
import { test as base } from '@playwright/test';
import fs from 'fs/promises';

export const test = base.extend({
  page: async ({ page }, use) => {
    const sessionData = await fs.readFile('tests/auth/sso-session.json', 'utf-8');
    await page.addInitScript((data) => {
      const items = JSON.parse(data);
      for (const [key, value] of Object.entries(items)) {
        window.sessionStorage.setItem(key, value as string);
      }
    }, sessionData);
    await use(page);
  },
});

Use this fixture in tests that need sessionStorage. The addInitScript runs before any page script, so by the time your app reads sessionStorage, it's populated.

When and How to Refresh Expired Tokens

Your auth files have a shelf life. JWTs expire. Refresh tokens expire. If your CI runs nightly and your tokens last 1 hour, you'll see all tests fail with 401s the next morning.

Two patterns:

Pattern 1: Re-run setup if files are stale

// tests/auth.setup.ts (head of file)
import fs from 'fs';

function isStale(file: string, maxAgeMinutes = 30): boolean {
  if (!fs.existsSync(file)) return true;
  const ageMs = Date.now() - fs.statSync(file).mtimeMs;
  return ageMs > maxAgeMinutes * 60 * 1000;
}

for (const user of USERS) {
  const authFile = path.join(__dirname, 'auth', `${user.role}.json`);
  if (!isStale(authFile)) {
    setup.skip(`authenticate as ${user.role}`, async () => {});
    continue;
  }
  setup(`authenticate as ${user.role}`, async ({ page }) => { ... });
}

Saves ~30 seconds per local run. In CI, files are always fresh anyway because each run starts clean.

Pattern 2: Refresh inside tests on 401

If your tokens are short-lived (under 5 minutes), use a fixture that refreshes mid-test:

test.beforeEach(async ({ page }) => {
  page.on('response', async (response) => {
    if (response.status() === 401 && response.url().includes('/api/')) {
      await page.evaluate(async () => {
        const r = await fetch('/api/auth/refresh', { method: 'POST' });
        const { token } = await r.json();
        localStorage.setItem('auth_token', token);
      });
    }
  });
});

FAQs

How many parallel users is too many?

I've run this pattern with 12 distinct roles without issues. The bottleneck isn't Playwright — it's your auth provider. Auth0 free tier rate-limits at 10 logins/sec, so a setup phase with 12 logins takes 1.2s minimum.

What about OAuth/SSO providers that block automation?

Use a programmatic API token where possible. Most SSO providers offer service-account tokens for testing. If forced to test through the UI, use a dedicated test tenant and whitelist your IPs.

Should I commit auth.json files for CI?

No — they contain real tokens. Generate them in a setup project that runs before your test projects. CI gets fresh files every run.

Does this work with Playwright Component Testing?

Component testing doesn't have the auth concept the same way. You'd mock the auth provider context directly. Different topic — see my Component Testing post.

Can I use storageState with the request fixture (API tests)?

Yes. request: { storageState: 'auth/admin.json' } in the project config or per test gives the API client the same cookies/headers as a logged-in browser.

What happens if my login flow has 2FA?

Disable 2FA on your test accounts via your auth provider's admin panel. If you can't disable it, generate a long-lived API token and inject it directly via localStorage in auth.setup.ts instead of going through the UI flow.

How do I test the login flow itself if every test skips it?

One project (auth-flow) doesn't use storageState and tests login/logout/forgot-password explicitly. The other projects skip login because they assume it works.

What about cookies set with HttpOnly Secure SameSite=Strict?

storageState captures all cookies regardless of flags. The browser context applies them on the next page load, same as if a real browser had them.

Can I share auth between tests in different files?

That's the entire point — every test in a project shares the same storageState file. They get separate browser contexts, but each context is initialized with the saved cookies and localStorage.

How do I debug a flaky setup project?

Run npx playwright test --project=setup --headed --workers=1. You'll see the actual login form. If it's blocked by something like CAPTCHA, see my Turnstile post.

Wrap-Up

Multi-role storage state is one of the highest-leverage patterns in Playwright. Get it right and your CI shaves minutes off every run. Get it wrong and you fight flaky logins forever.

If you want help wiring this into your existing suite — especially if you're transitioning from a Selenium framework that used BeforeMethod logins — I do framework setup engagements that include this exact pattern. Or book a free call and we'll walk through your auth flow together.

Related reading:

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire