Handling Auth & Sessions

Master login flows and reuse authentication state across tests

9 min readNode.js

Handling Auth & Sessions

In the previous tutorial, we set up traces and screenshots for debugging. Now let's solve a problem that affects almost every real-world test suite: authentication.

The naive approach — logging in at the start of every test — is slow and fragile. Playwright has a better way: authenticate once, save the session, and reuse it across all tests. Let's learn how.

The Problem

Without optimization, every test looks like this:

test('view dashboard', async ({ page }) => {
  // Login AGAIN
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  
  // Now the actual test...
  await expect(page.getByText('Welcome')).toBeVisible();
});

test('view profile', async ({ page }) => {
  // Login AGAIN
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  // ...
});

If login takes 2 seconds and you have 100 tests, that's 200 seconds wasted just on login. Plus, if the login UI changes, every test breaks. Yikes.

Testing the Login Flow Itself

First, let's properly test the login feature:

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

test.describe('authentication', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    await page.goto('/login');
    
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome back')).toBeVisible();
  });
  
  test('invalid credentials shows error', async ({ page }) => {
    await page.goto('/login');
    
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: 'Sign in' }).click();
    
    await expect(page.getByRole('alert')).toHaveText('Invalid email or password');
    await expect(page).toHaveURL('/login');
  });
  
  test('logout clears session', async ({ page }) => {
    // Login first
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    
    // Now logout
    await page.getByRole('button', { name: 'Logout' }).click();
    
    await expect(page).toHaveURL('/login');
    
    // Verify session is cleared
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/login'); // Redirected
  });
});

Test the login feature once, thoroughly. Other tests should skip this.

Saving Authentication State

"How do I avoid logging in for every single test?"

Here's the magic: log in once, save the session (cookies, localStorage, etc.), and reuse it.

Step 1: Create a Setup File

Create a file that runs before your tests and saves the auth state:

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

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
  // Perform login
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  
  // Wait for login to complete
  await page.waitForURL('/dashboard');
  
  // Verify we're logged in
  await expect(page.getByText('Welcome')).toBeVisible();
  
  // Save the authentication state
  await page.context().storageState({ path: authFile });
});

Step 2: Configure Playwright

Set up your config to run the setup first, then use its output:

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

export default defineConfig({
  projects: [
    // Setup project - runs first
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    
    // Main tests - depend on setup
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Use saved auth state
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
    },
    
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Step 3: Add Auth Directory to .gitignore

# .gitignore
.auth/

Now every test in chromium and firefox projects starts already logged in. No login code needed! Boom!

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

// No login! We're already authenticated
test('dashboard shows user data', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByText('Welcome')).toBeVisible();
});

test('can update profile', async ({ page }) => {
  await page.goto('/profile');
  await expect(page.getByLabel('Name')).toBeVisible();
});

Multiple User Roles

"What if I have admin users and regular users?"

Most apps have different user types — regular users, admins, etc. Here's how to handle that.

Separate Setup Files

Create a setup file for each role:

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

const userFile = path.join(__dirname, '../.auth/user.json');
const adminFile = path.join(__dirname, '../.auth/admin.json');

setup('authenticate as user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('user123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: userFile });
});

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('admin@example.com');
  await page.getByLabel('Password').fill('admin123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/admin');
  await page.context().storageState({ path: adminFile });
});

Projects for Each Role

// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    
    // User tests
    {
      name: 'user-tests',
      testDir: './tests/user',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
    },
    
    // Admin tests
    {
      name: 'admin-tests',
      testDir: './tests/admin',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/admin.json',
      },
      dependencies: ['setup'],
    },
    
    // No-auth tests (login page, public pages)
    {
      name: 'no-auth',
      testDir: './tests/public',
      use: {
        ...devices['Desktop Chrome'],
        // No storageState - fresh session
      },
    },
  ],
});

Using Fixtures for Roles

Alternatively, use fixtures to provide different auth states:

// fixtures.ts
import { test as base, Page } from '@playwright/test';

type AuthFixtures = {
  userPage: Page;
  adminPage: Page;
};

export const test = base.extend<AuthFixtures>({
  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: '.auth/user.json',
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
  
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: '.auth/admin.json',
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';

Use in tests:

import { test, expect } from './fixtures';

test('admin can see user management', async ({ adminPage }) => {
  await adminPage.goto('/admin/users');
  await expect(adminPage.getByRole('heading', { name: 'User Management' })).toBeVisible();
});

test('user cannot see admin panel', async ({ userPage }) => {
  await userPage.goto('/admin');
  await expect(userPage).toHaveURL('/dashboard'); // Redirected
});

API-Based Authentication

"Can I skip the login UI entirely?"

For even faster setup, bypass the UI and authenticate via API:

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

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate via API', async ({ request, browser }) => {
  // Login via API
  const response = await request.post('/api/auth/login', {
    data: {
      email: 'user@example.com',
      password: 'password123',
    },
  });
  
  // Get the auth token or cookies from response
  const { token } = await response.json();
  
  // Create a browser context and set the token
  const context = await browser.newContext();
  
  // Option 1: Set cookie directly
  await context.addCookies([{
    name: 'auth-token',
    value: token,
    domain: 'localhost',
    path: '/',
  }]);
  
  // Option 2: Set localStorage (need to navigate first)
  const page = await context.newPage();
  await page.goto('/');
  await page.evaluate((token) => {
    localStorage.setItem('auth-token', token);
  }, token);
  
  // Save the state
  await context.storageState({ path: authFile });
  await context.close();
});

This is faster than UI login and less fragile. The smart way to do it.

Authenticated API Requests

Need to make API calls with authentication in your tests?

test('API request with auth', async ({ request }) => {
  // If using storageState, cookies are already set
  const response = await request.get('/api/user/profile');
  expect(response.ok()).toBeTruthy();
  
  const profile = await response.json();
  expect(profile.email).toBe('user@example.com');
});

Or create an authenticated request context:

// fixtures.ts
import { test as base, APIRequestContext } from '@playwright/test';

export const test = base.extend<{
  authRequest: APIRequestContext;
}>({
  authRequest: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      storageState: '.auth/user.json',
    });
    await use(context);
    await context.dispose();
  },
});

Handling Special Cases

MFA / Two-Factor Authentication

MFA in tests is tricky. Options:

Option 1: Disable in test environment

// Your app checks an environment variable
if (process.env.NODE_ENV === 'test') {
  // Skip MFA
}

Option 2: Use a test user without MFA

Create a dedicated test user with MFA disabled.

Option 3: Use backup codes or TOTP library

import * as OTPAuth from 'otpauth';

setup('authenticate with MFA', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  
  // Generate TOTP code
  const totp = new OTPAuth.TOTP({
    secret: process.env.TEST_USER_MFA_SECRET!, // Store securely
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
  });
  
  const code = totp.generate();
  await page.getByLabel('Authentication code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();
  
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: '.auth/user.json' });
});

OAuth / Social Login

OAuth is harder because you can't control third-party login pages. Options:

Option 1: Mock the OAuth flow

// Intercept the OAuth callback and inject the token
setup('mock OAuth', async ({ page }) => {
  // Intercept the callback URL
  await page.route('**/auth/callback**', async (route) => {
    // Redirect to your app with a mock token
    await route.fulfill({
      status: 302,
      headers: {
        'Location': '/auth/callback?token=mock-test-token',
      },
    });
  });
  
  await page.goto('/login');
  await page.getByRole('button', { name: 'Login with Google' }).click();
  
  // Your app handles the mock token
  await page.waitForURL('/dashboard');
});

Option 2: API-based auth

If your app supports it, create a test endpoint that generates auth tokens directly:

setup('test login endpoint', async ({ request }) => {
  // Only available in test environment
  const response = await request.post('/api/test/create-session', {
    data: { userId: 'test-user-id' },
  });
  // ...
});

Option 3: Use real credentials (carefully)

Store test account credentials securely and actually go through OAuth. Slow but realistic.

Session Expiry

For long-lived auth states, handle potential expiry:

test.beforeEach(async ({ page }) => {
  // Check if still logged in
  await page.goto('/dashboard');
  
  // If redirected to login, re-authenticate
  if (page.url().includes('/login')) {
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
  }
});

Or simply regenerate auth state before each test run in CI.

Complete Configuration Example

Here's a full config tying it all together:

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'retain-on-failure',
  },
  
  projects: [
    // Authentication setup
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    
    // Tests that require user auth
    {
      name: 'authenticated',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
      testIgnore: /.*\.(setup|noauth)\.ts/,
    },
    
    // Tests that require admin auth
    {
      name: 'admin',
      testDir: './tests/admin',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/admin.json',
      },
      dependencies: ['setup'],
    },
    
    // Tests without authentication (login page, public pages)
    {
      name: 'unauthenticated',
      testMatch: /.*\.noauth\.ts/,
      use: {
        ...devices['Desktop Chrome'],
      },
    },
  ],
  
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

What's Next?

Authentication is no longer a bottleneck:

  • Login once, save state, reuse everywhere
  • Use setup projects to authenticate before tests
  • Handle multiple user roles with separate auth files
  • API-based auth is faster than UI login
  • Workarounds for MFA and OAuth
  • Configure projects for authenticated and unauthenticated tests

Now that you can handle auth, let's learn to mock API responses for faster, more reliable tests. Let's go!