Playwright Authentication: 5 Patterns Every QA Must Know

Stop repeating login flows in every test. This guide covers five Playwright authentication patterns with production-ready code, from storageState reuse to OAuth and bearer token injection.

Most modern web apps hide their core features behind a login wall. If your end-to-end tests cannot get past that wall reliably, every test after it becomes useless.

The real pain is not writing the login code once. It is running it before every single test, watching your CI pipeline burn through minutes of repeated sign-in flows, and then debugging session failures that have nothing to do with the feature you actually wanted to test.

This guide walks through five playwright authentication patterns, from the simplest cookie-based reuse to full OAuth and bearer token injection, with working code you can drop into your Playwright test automation project today. It assumes you have Playwright installed and a basic test suite running.

What is playwright authentication and why does it matter

Playwright authentication is the process of signing into your application inside a test so that all subsequent actions run as a logged-in user. It uses browser context isolation and storage to save and restore cookies, localStorage, and session data across tests.

Without proper authentication handling, every test that touches a protected page would need to go through the login flow from scratch.

That repeated login creates two problems. First, it slows down your entire e2e testing suite because the browser has to render the login page, fill credentials, and wait for redirects every single time.

Second, it introduces a shared point of failure. If the login page changes even slightly, every test in your suite breaks at once.

Playwright solves this with a concept called storageState. According to the official Playwright documentation, Playwright executes tests in isolated environments called browser contexts. Tests can load existing authenticated state, which eliminates the need to authenticate in every test and speeds up execution.

The idea is simple:

  • Log in once in a setup step

  • Save the cookies, localStorage, and IndexedDB data to a JSON file

  • Load that file into every test that needs authentication

Tip: This "authenticate once, reuse everywhere" approach can cut total suite runtime by 40-60% on projects with 100+ tests. The difference between each pattern below is how and where you perform that initial login.

Before we get into the patterns, here is the folder structure Playwright recommends. Create a playwright/.auth directory and add it to your .gitignore right away.

Security Note: The auth state files contain sensitive session cookies that could be used to impersonate your test accounts. Never commit them to version control. Add playwright/.auth/ to your .gitignore immediately

terminal
mkdir -p playwright/.auth
echo "playwright/.auth" >> .gitignore

Pattern 1: storageState for reusable authenticated sessions

This is the most common playwright login automation pattern and the one Playwright officially recommends for most teams. You log in once inside a setup project, save the browser state, and every test after that starts already authenticated.

How it works

The storageState method serializes all cookies, localStorage entries, and IndexedDB data from the current browser context into a JSON file. Loading this file into a new context restores the exact session state without re-authenticating.

You create a dedicated setup file that performs the login through the browser UI, waits for the session to stabilize, and then calls page.context().storageState() to dump all cookies and local storage into a JSON file.

Your test config then tells every browser project to load that JSON before running any tests.

Here is the setup file:

tests/auth.setup.ts
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 }) => {
  await page.goto('https://your-app.com/login');
  await page.getByLabel('Username or email address').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  // Wait for the final redirect to confirm cookies are set
  await page.waitForURL('https://your-app.com/dashboard');
  // Save the authenticated state
  await page.context().storageState({ path: authFile });
});

Now wire it into your Playwright configuration:

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
  projects: [
    // Setup project runs first
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Once this is configured, every test file automatically starts with a logged-in session. No extra code needed inside the test itself:

tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test('dashboard loads user data', async ({ page }) => {
  // page is already authenticated
  await page.goto('https://your-app.com/dashboard');
  await expect(page.getByText('Welcome, testuser')).toBeVisible();
});

Why project dependencies beat globalSetup

Older guides often recommend using globalSetup for authentication. That approach works, but it has real drawbacks. The setup does not appear in your HTML report, you cannot attach traces or screenshots to it, and you cannot use Playwright fixtures inside it.

Project dependencies (available since Playwright v1.31) solve all three problems. The setup runs as a normal test, shows up in reports, and supports full debugging. If your authentication step fails in CI, you can see exactly what happened in the trace viewer instead of guessing from a generic error log.

Track every test run in real time
See pass/fail trends, flaky tests, and CI health instantly.
Start free CTA Graphic

Pattern 2: API-based login for faster auth setup

The UI-based login from Pattern 1 works, but it is slow. The browser has to render the login page, execute any client-side JavaScript, and follow multiple redirects. If your app supports a login API endpoint, you can skip all of that.

Performance Tip: API-based login cuts authentication setup from 2-5 seconds (UI login) down to under 500 milliseconds. Across a suite of 200 tests running in CI, that difference translates to minutes saved per pipeline run.

This pattern uses Playwright's built-in APIRequestContext to send a direct HTTP request to your authentication endpoint. The response sets the session cookies, and you save them with storageState just like before.

tests/auth.setup.ts
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate via API', async ({ request }) => {
  // Send a direct POST to the login endpoint
  await request.post('https://your-app.com/api/auth/login', {
    data: {
      email: process.env.TEST_USER_EMAIL,
      password: process.env.TEST_USER_PASSWORD,
    },
  });
  // Save cookies and storage from the API response
  await request.storageState({ path: authFile });
});

Security Note: Never hardcode passwords in test files. Store credentials in a .env file locally and use your CI provider's secret management (GitHub Secrets, GitLab CI Variables, etc.) for pipeline runs.

When API auth makes sense

This approach is ideal when your backend exposes a REST or GraphQL login endpoint. It cuts the authentication step from 2 to 5 seconds (UI login) down to under 500 milliseconds. Across a suite of 200 tests running in CI, that difference adds up fast.

However, API-based auth skips the actual login UI entirely. If your login form has custom validation, CAPTCHA, or multi-step flows, you still need the UI-based approach from Pattern 1 for at least one dedicated login test. Use the API approach for everything else.

This is the same strategy recommended for Playwright API testing in general. You can mix UI and API calls in the same suite, using the API for setup and the browser for the actual feature tests.

Pattern 3: multi-role authentication testing

Real applications do not have just one type of user. You have admins, regular users, editors, viewers, and guest accounts. Each role sees different pages, buttons, and data.

This playwright authentication pattern extends the storageState approach to manage multiple sessions. Testing all roles means saving separate JSON files for each account.

Setting up multiple roles

The approach builds directly on Pattern 1. You create separate setup steps for each role and save their state to different JSON files:

tests/auth.setup.ts
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('**/admin/dashboard');
  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('**/dashboard');
  await page.context().storageState({ path: userFile });
});

Using roles in your tests

Inside your test files, use test.use() to pick the role you need:

tests/admin-panel.spec.ts
import { test, expect } 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');
  await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
});

tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/user.json' });
test('regular user cannot see admin panel', async ({ page }) => {
  await page.goto('https://your-app.com/admin/users');
  await expect(page).toHaveURL('**/unauthorized');
});

Testing role interactions in a single test

A browser context in Playwright is an isolated session equivalent to an incognito window. Each context has its own cookies, localStorage, and cache. Opening multiple contexts in a single test lets you simulate multiple users interacting simultaneously.

Sometimes you need to verify how two roles interact. For example, an admin approves a request that a user submitted. Playwright lets you open multiple browser contexts in the same test, each with its own storageState:

tests/approval-flow.spec.ts
import { test, expect } from '@playwright/test';
test('admin approves user request', async ({ browser }) => {
  // Create two isolated contexts with different roles
  const adminContext = await browser.newContext({
    storageState: 'playwright/.auth/admin.json',
  });
  const userContext = await browser.newContext({
    storageState: 'playwright/.auth/user.json',
  });
  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();
  // User submits a request
  await userPage.goto('https://your-app.com/requests/new');
  await userPage.getByLabel('Request title').fill('Need access to reports');
  await userPage.getByRole('button', { name: 'Submit' }).click();
  // Admin approves it
  await adminPage.goto('https://your-app.com/admin/requests');
  await adminPage.getByText('Need access to reports').click();
  await adminPage.getByRole('button', { name: 'Approve' }).click();
  // User sees the approval
  await userPage.reload();
  await expect(userPage.getByText('Approved')).toBeVisible();
  await adminContext.close();
  await userContext.close();
});

This multi-context approach is powerful for testing workflows that span roles, and it keeps everything within a single test so the flow reads like a story. You can further clean this up with page object models and custom Playwright fixtures for each role.

Pattern 4: bearer token injection for API-level auth

Not every test needs a browser. This playwright authentication pattern targets API endpoints that sit behind auth headers. You do not need to render a login page at all - just attach the right Authorization header to your requests.

This is a core concept in Playwright network mocking and API testing.

Playwright's APIRequestContext supports custom headers out of the box. You can fetch a token from your auth server and inject it into every subsequent API call.

Fetching and injecting a bearer token

tests/api-auth.spec.ts
import { test, expect } from '@playwright/test';
let token: string;
test.beforeAll(async ({ request }) => {
  // Get a token from the auth endpoint
  const response = await request.post('https://your-app.com/api/auth/token', {
    data: {
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      grant_type: 'client_credentials',
    },
  });
  const body = await response.json();
  token = body.access_token;
});
test('fetch user profile via API', async ({ request }) => {
  const response = await request.get('https://your-app.com/api/users/me', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
  expect(response.ok()).toBeTruthy();
  const user = await response.json();
  expect(user.email).toBeDefined();
});
test('create a new resource via API', async ({ request }) => {
  const response = await request.post('https://your-app.com/api/resources', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    data: {
      name: 'Test Resource',
      type: 'document',
    },
  });
  expect(response.status()).toBe(201);
});

Injecting tokens into browser-based tests

Sometimes your frontend reads a token from localStorage or a cookie and sends it as a header on every fetch call. You can inject this token directly into the browser context without going through the login flow:

tests/token-injection.spec.ts
import { test, expect } from '@playwright/test';
test('inject token into localStorage', async ({ page, request }) => {
  // Get token via API
  const response = await request.post('https://your-app.com/api/auth/token', {
    data: {
      email: process.env.TEST_USER_EMAIL,
      password: process.env.TEST_USER_PASSWORD,
    },
  });
  const { access_token } = await response.json();
  // Navigate to the app domain first (localStorage is domain-specific)
  await page.goto('https://your-app.com');
  // Inject the token into localStorage
  await page.evaluate((tkn) => {
    localStorage.setItem('auth_token', tkn);
  }, access_token);
  // Now navigate to a protected page
  await page.goto('https://your-app.com/dashboard');
  await expect(page.getByText('Welcome')).toBeVisible();
});

Tip: Token injection gives you full control over the token value. Use it to test edge cases like expired tokens, malformed JWTs, or tokens with specific role claims without needing separate test accounts for each scenario.

This pattern is especially useful for single-page applications that store JWTs in localStorage. It avoids the login UI entirely and is ideal for testing authorization edge cases that would be difficult to reproduce through normal login flows.

For teams running large suites of Playwright scripts that mix UI and API testing, this pattern keeps your auth layer thin and predictable.

Pattern 5: handling OAuth and SSO flows

OAuth and SSO are the hardest playwright authentication flows to automate. They involve redirects to third-party identity providers (Google, GitHub, Okta, Azure AD), consent screens, and callback URLs that your test environment may not fully control.

There are three strategies for handling this, ranked from most reliable to most fragile.

Strategy A: bypass the UI with a direct token exchange

If your identity provider supports a direct token endpoint (most OAuth 2.0 providers do), you can skip the browser-based flow entirely. You send a POST request to the token endpoint with client credentials or a resource-owner password grant, get back an access token, and inject it into your tests.

tests/oauth-setup.ts
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/oauth-user.json';
setup('authenticate via OAuth token endpoint', async ({ request, page }) => {
  // Direct token request to the identity provider
  const tokenResponse = await request.post(
    'https://auth.provider.com/oauth/token',
    {
      form: {
        grant_type: 'password',
        client_id: process.env.OAUTH_CLIENT_ID!,
        client_secret: process.env.OAUTH_CLIENT_SECRET!,
        username: process.env.OAUTH_TEST_USER!,
        password: process.env.OAUTH_TEST_PASSWORD!,
        scope: 'openid profile email',
      },
    }
  );
  const { access_token } = await tokenResponse.json();
  // Set the token in localStorage or cookies as your app expects
  await page.goto('https://your-app.com');
  await page.evaluate((tkn) => {
    localStorage.setItem('id_token', tkn);
  }, access_token);
  await page.context().storageState({ path: authFile });
});

This is the fastest and most stable approach. It does not depend on the provider's UI, so it will not break when Google changes their login page layout.

Strategy B: automate the provider's login UI

If you cannot use a direct token exchange (your provider does not support resource-owner password grants, or you need to test the actual SSO redirect flow), you have to automate the provider's login page.

tests/sso-setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/sso-user.json';
setup('authenticate via SSO', async ({ page }) => {
  // Start the login flow on your app
  await page.goto('https://your-app.com/login');
  await page.getByRole('button', { name: 'Sign in with Google' }).click();
  // You are now on Google's login page
  await page.getByLabel('Email or phone').fill(process.env.GOOGLE_TEST_EMAIL!);
  await page.getByRole('button', { name: 'Next' }).click();
  await page.getByLabel('Enter your password').fill(process.env.GOOGLE_TEST_PASSWORD!);
  await page.getByRole('button', { name: 'Next' }).click();
  // Wait for redirect back to your app
  await page.waitForURL('https://your-app.com/dashboard');
  await page.context().storageState({ path: authFile });
});

This approach is fragile because you are automating a third-party UI that you do not control. Google, Microsoft, and other providers frequently update their login pages, which is a common source of test failures. If you go this route, keep the SSO setup isolated in a single file so you only need to fix it in one place when the provider's UI changes.

Strategy C: use a test-only auth route

Many teams add a special authentication endpoint to their staging or test environments that bypasses the identity provider entirely. This route accepts a user ID or email and returns a valid session without any OAuth dance.

tests/test-auth-setup.ts
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate via test route', async ({ request }) => {
  // Hit a test-only endpoint that creates a session
  await request.post('https://staging.your-app.com/api/test/auth', {
    data: {
      userId: 'test-user-123',
      role: 'admin',
    },
  });
  await request.storageState({ path: authFile });
});

This is the most reliable strategy for OAuth-heavy apps. It keeps your tests independent of any identity provider, and it lets you create sessions for any role or user on demand.

Security Note: If you create a test-only authentication bypass route, protect it aggressively. Gate it behind an environment variable check (e.g., if (process.env.NODE_ENV !== 'test') return 403) and never deploy it to production. A leaked bypass route is a critical security vulnerability.

Debug flaky auth tests faster
AI-powered failure analysis pinpoints the root cause instantly.
Try free CTA Graphic

Comparing all five playwright authentication patterns

Choosing the right playwright authentication pattern depends on your app's auth system, your team's speed requirements, and how many user roles you need to test. Here is a side-by-side comparison:

Pattern Auth method Speed Best for Limitation
storageState (Pattern 1) Browser UI login Medium (2-5s setup) Standard form-based login apps Slower than API auth
API login (Pattern 2) HTTP POST to login endpoint Fast (under 500ms) Apps with REST/GraphQL auth APIs Skips login UI testing
Multi-role (Pattern 3) Multiple storageState files Medium (scales with roles) Apps with admin/user/editor roles Requires separate test accounts
Bearer token (Pattern 4) Direct header injection Fast (under 300ms) API-only testing, JWT-based apps Does not verify cookie-based flows
OAuth/SSO (Pattern 5) Token exchange or provider UI Varies (depends on strategy) Google/Azure/Okta SSO integrations Most complex to maintain

Most teams end up using a combination. Pattern 2 (API login) for the bulk of their tests, Pattern 1 (UI login) for a single dedicated login test, and Pattern 3 (multi-role) for permission-related scenarios.

Source: Measured using Playwright v1.49 on a typical Node.js test environment with a local dev server. Values represent the average across 10 consecutive runs of each auth setup method.

Common playwright authentication mistakes and fixes

Even with the right pattern in place, there are several mistakes that can make your authenticated tests unreliable. These are the ones that show up most often in real projects.

Hardcoding credentials in test files

This is the most common mistake. Credentials committed to version control are a security risk and make it impossible to run the same tests against different environments without code changes.

Always use environment variables. Store them in a .env file locally and use your CI provider's secret management for pipelines. The Playwright configuration supports dotenv out of the box.

Not waiting for the redirect chain to finish

Login flows often involve multiple redirects. If you save storageState before all redirects complete, the cookies may be incomplete. Always use page.waitForURL() to wait for the final destination, or wait for a specific element on the post-login page.

Forgetting to add auth files to .gitignore

The storageState JSON file contains session cookies that can be used to impersonate your test account. If this file gets committed and pushed, anyone with access to the repo can hijack that session. Add playwright/.auth/ to your .gitignore immediately.

Using the same account for parallel workers

When tests run in parallel, each worker process gets its own browser context. If all workers use the same test account and your tests modify server-side state (like changing settings), they will interfere with each other and cause flaky tests.

The fix is to use one account per parallel worker, as described in the official Playwright docs. Use testInfo.parallelIndex to pick a unique account for each worker.

Tip: Use testInfo.parallelIndex in your auth setup to dynamically pick credentials. For example: const user = users[testInfo.parallelIndex]. This ensures each worker gets its own isolated account, eliminating cross-worker state interference.

Ignoring session expiration

Tokens and sessions expire. If your CI pipeline caches auth state files between runs and the session expires before the next run, all tests will fail at the first navigation. Either regenerate the auth state on every run (the default behavior with project dependencies) or handle token refresh in your setup.

Skipping unauthenticated test coverage

If every test in your suite runs with a logged-in session, you are not testing what happens when unauthenticated users visit protected pages. Playwright makes it easy to reset the storage state for specific test files:

tests/unauthenticated.spec.ts
import { test, expect } from '@playwright/test';
// Reset storage state to run without authentication
test.use({ storageState: { cookies: [], origins: [] } });
test('redirect to login when not authenticated', async ({ page }) => {
  await page.goto('https://your-app.com/dashboard');
  await expect(page).toHaveURL('**/login');
});

Teams that track their test failure patterns in a test reporting tool often find that auth-related failures account for a significant portion of their flaky tests. Getting the auth setup right is one of the highest-leverage improvements you can make to a test suite.

Monitor your test suite health
Get AI failure analysis and flaky test detection out of the box.
Get started CTA Graphic

Conclusion

Playwright authentication does not have to be the weak point of your test suite. With storageState, you authenticate once and reuse that session across hundreds of tests. With API-based login, you cut setup time to under a second. With multi-role setups, you cover every permission level without duplicating login code.

The five patterns in this guide cover the full spectrum:

  • Pattern 1 (storageState) is the default choice for most projects

  • Pattern 2 (API login) speeds up setup by skipping the browser

  • Pattern 3 (multi-role) handles admin, user, and guest scenarios

  • Pattern 4 (bearer token) works for API-only testing and JWT apps

  • Pattern 5 (OAuth/SSO) tackles third-party identity providers

Start with Pattern 1 if you are new to Playwright authentication. Once your suite grows, layer in Pattern 2 for speed and Pattern 3 for role coverage. The key is to keep your auth logic in one place (the setup file) and let every test inherit it through the config. For a deeper dive into structuring your entire project, see the guide on reducing test maintenance in Playwright.

FAQs

What does storageState save in Playwright?
storageState saves cookies, localStorage, and IndexedDB data into a JSON file. It does not save sessionStorage, which is tab-specific and requires manual handling.
Can I use Playwright authentication with CI/CD pipelines?
Yes. Store credentials as CI secrets, reference them via process.env, and the project dependency setup generates a fresh auth state on every pipeline run. Works with GitHub Actions, GitLab CI, and Jenkins.
How do I handle MFA or two-factor authentication in Playwright?
For TOTP-based MFA, use a library like otplib to generate codes programmatically in your setup script. For SMS or email-based MFA, disable it for test accounts in staging or use a test-only bypass route.
Does Playwright support session storage reuse?
Not natively. You can manually save it with page.evaluate(() => JSON.stringify(sessionStorage)) and restore it via context.addInitScript(). See the official Playwright auth guide for details.
How do I run specific tests without authentication?
Add test.use({ storageState: { cookies: [], origins: [] } }) at the top of the test file. This resets auth for that file only while all other tests remain authenticated.
What is the difference between globalSetup and project dependencies for auth?
globalSetup runs once before the suite but lacks fixture support, report visibility, and trace generation. Project dependencies run auth as a regular test, giving you full debugging, HTML report entries, and screenshot capabilities.
Jashn Jain

Product & Growth Engineer

Jashn Jain is a Product and Growth Engineer at TestDino, focusing on automation strategy, developer tooling, and applied AI in testing. Her work involves shaping Playwright based workflows and creating practical resources that help engineering teams adopt modern automation practices.

She contributes through product education and research, including presentations at CNR NANOTEC and publications in ACL Anthology, where her work examines explainability and multimodal model evaluation.

Get started fast

Step-by-step guides, real-world examples, and proven strategies to maximize your test reporting success