authentication-testing

star 0

Storage state reuse, 2FA/TOTP testing, multi-role auth, session management, OAuth flows, and secure credential handling in Playwright

AbhiRKeesara By AbhiRKeesara schedule Updated 2/9/2026

name: Authentication Testing description: Storage state reuse, 2FA/TOTP testing, multi-role auth, session management, OAuth flows, and secure credential handling in Playwright

Authentication Testing Skill

Overview

Authentication is the most common setup step in end-to-end testing. This skill covers how to efficiently handle login flows, reuse auth state across tests, test 2FA/TOTP, manage multiple roles, and avoid common auth-related test failures.

The #1 Rule: Authenticate Once, Reuse Everywhere

// ❌ BAD: Every test logs in through the UI (slow, flaky)
test('view profile', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  // NOW the actual test begins...
  await page.goto('/profile');
});

// ✅ GOOD: Auth state saved once, reused via storageState
test('view profile', async ({ page }) => {
  // Already authenticated via storageState in config!
  await page.goto('/profile');
  await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
});

Storage State Pattern

1. Save Auth State with Setup Project

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

export default defineConfig({
  projects: [
    // Auth setup runs first
    {
      name: 'auth-setup',
      testMatch: /auth\.setup\.ts/,
    },
    // All test projects depend on auth setup
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      dependencies: ['auth-setup'],
    },
  ],
});

2. Auth Setup File

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

const USER_AUTH_FILE = '.auth/user.json';

setup('authenticate as standard user', async ({ page }) => {
  // Step 1: Navigate to login
  await page.goto('/login');

  // Step 2: Fill credentials
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);

  // Step 3: Submit and wait for redirect
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');

  // Step 4: Verify we're logged in
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Step 5: Save auth state (cookies + localStorage)
  await page.context().storageState({ path: USER_AUTH_FILE });
});

3. Add .auth to .gitignore

# .gitignore
.auth/

Multi-Role Authentication

4. Different Roles with Different Storage States

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

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/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');
  await page.context().storageState({ path: '.auth/admin.json' });
});

setup('authenticate as editor', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.EDITOR_EMAIL!);
  await page.getByLabel('Password').fill(process.env.EDITOR_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/editor');
  await page.context().storageState({ path: '.auth/editor.json' });
});

setup('authenticate as viewer', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.VIEWER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.VIEWER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: '.auth/viewer.json' });
});
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'auth-setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'admin-tests',
      testDir: './tests/admin',
      use: { storageState: '.auth/admin.json' },
      dependencies: ['auth-setup'],
    },
    {
      name: 'editor-tests',
      testDir: './tests/editor',
      use: { storageState: '.auth/editor.json' },
      dependencies: ['auth-setup'],
    },
    {
      name: 'viewer-tests',
      testDir: './tests/viewer',
      use: { storageState: '.auth/viewer.json' },
      dependencies: ['auth-setup'],
    },
  ],
});

5. Override Auth Per Test

// Use a different role for specific tests
test.use({ storageState: '.auth/admin.json' });

test('admin can delete users', async ({ page }) => {
  await page.goto('/admin/users');
  // Already logged in as admin
});

6. No Auth for Specific Tests

// Tests that need no authentication (login page, public pages)
test.use({ storageState: { cookies: [], origins: [] } });

test('login page shows form', async ({ page }) => {
  await page.goto('/login');
  await expect(page.getByLabel('Email')).toBeVisible();
});

Two-Factor Authentication (2FA / TOTP)

7. TOTP with otplib

npm install --save-dev otplib
// tests/auth-2fa.setup.ts
import { test as setup } from '@playwright/test';
import { authenticator } from 'otplib';

setup('authenticate with 2FA', async ({ page }) => {
  // Step 1: Standard login
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Step 2: Wait for 2FA prompt
  await expect(page.getByText('Enter verification code')).toBeVisible();

  // Step 3: Generate TOTP code from secret
  const secret = process.env.TOTP_SECRET!;
  const totpCode = authenticator.generate(secret);

  // Step 4: Enter the TOTP code
  await page.getByLabel('Verification code').fill(totpCode);
  await page.getByRole('button', { name: 'Verify' }).click();

  // Step 5: Wait for auth to complete
  await page.waitForURL('/dashboard');

  // Step 6: Save state
  await page.context().storageState({ path: '.auth/user-2fa.json' });
});

8. Handle TOTP Timing Issues

import { authenticator } from 'otplib';

// TOTP codes are time-based (30-second windows)
// If we're near the end of a window, the code might expire before submission
function getValidTotpCode(secret: string): string {
  const timeRemaining = authenticator.timeRemaining();

  // If less than 5 seconds remaining, wait for next window
  if (timeRemaining < 5) {
    const waitMs = (timeRemaining + 1) * 1000;
    // Use synchronous delay to wait for next TOTP window
    const start = Date.now();
    while (Date.now() - start < waitMs) {
      // busy wait
    }
  }

  return authenticator.generate(secret);
}

API-Based Authentication (Faster)

9. Skip UI Login — Authenticate via API

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

setup('authenticate via API', async ({ request, page }) => {
  // Login via API (much faster than UI)
  const response = await request.post('/api/auth/login', {
    data: {
      email: process.env.TEST_USER_EMAIL!,
      password: process.env.TEST_USER_PASSWORD!,
    },
  });

  expect(response.ok()).toBeTruthy();
  const { token } = await response.json();

  // Set the token in browser context
  await page.goto('/');
  await page.evaluate((authToken) => {
    localStorage.setItem('auth_token', authToken);
  }, token);

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

10. Cookie-Based API Auth

setup('authenticate via API with cookies', async ({ request }) => {
  // API login returns Set-Cookie headers
  const response = await request.post('/api/auth/login', {
    data: {
      email: process.env.TEST_USER_EMAIL!,
      password: process.env.TEST_USER_PASSWORD!,
    },
  });

  // Cookies are automatically captured in the request context
  // Save the storage state including cookies
  await request.storageState({ path: '.auth/user.json' });
});

Session Management

11. Handle Session Expiry

// fixtures/auth-fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  // Auto-fixture: check session validity before each test
  ensureAuthenticated: [async ({ page }, use) => {
    // Check if session is still valid
    const response = await page.request.get('/api/auth/me');

    if (response.status() === 401) {
      // Session expired — re-authenticate
      console.warn('Session expired, re-authenticating...');
      await page.goto('/login');
      await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
      await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
      await page.getByRole('button', { name: 'Sign in' }).click();
      await page.waitForURL('/dashboard');
    }

    await use();
  }, { auto: true }],
});

12. Test Logout Behavior

test('user can log out', async ({ page }) => {
  await page.goto('/dashboard');

  // Click logout
  await page.getByRole('button', { name: 'Account menu' }).click();
  await page.getByRole('menuitem', { name: 'Log out' }).click();

  // Verify redirect to login
  await expect(page).toHaveURL('/login');

  // Verify protected routes are inaccessible
  await page.goto('/dashboard');
  await expect(page).toHaveURL(/\/login/);
});

13. Test Session Timeout

test('expired session redirects to login', async ({ browser }) => {
  // Create a new context with expired cookies
  const context = await browser.newContext({
    storageState: {
      cookies: [{
        name: 'session',
        value: 'expired-token-value',
        domain: 'localhost',
        path: '/',
        expires: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
        httpOnly: true,
        secure: false,
        sameSite: 'Lax',
      }],
      origins: [],
    },
  });

  const page = await context.newPage();
  await page.goto('/dashboard');

  // Should redirect to login
  await expect(page).toHaveURL(/\/login/);
  await context.close();
});

OAuth / SSO Testing

14. Mock OAuth Provider

test('OAuth login flow', async ({ page }) => {
  // Intercept the OAuth redirect to mock the provider
  await page.route('**/oauth/authorize*', async (route) => {
    const url = new URL(route.request().url());
    const redirectUri = url.searchParams.get('redirect_uri')!;
    const state = url.searchParams.get('state')!;

    // Simulate successful OAuth callback
    await route.fulfill({
      status: 302,
      headers: {
        location: `${redirectUri}?code=mock-auth-code&state=${state}`,
      },
    });
  });

  // Mock the token exchange
  await page.route('**/oauth/token', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        access_token: 'mock-access-token',
        token_type: 'bearer',
        expires_in: 3600,
      }),
    });
  });

  // Click SSO login button
  await page.goto('/login');
  await page.getByRole('button', { name: 'Sign in with Google' }).click();

  // Should complete OAuth flow and land on dashboard
  await expect(page).toHaveURL('/dashboard');
});

Credential Management

15. Environment Variables (Required)

# .env.test (NEVER commit this file)
TEST_USER_EMAIL=testuser@example.com
TEST_USER_PASSWORD=secure-test-password
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin-secure-password
TOTP_SECRET=JBSWY3DPEHPK3PXP
// playwright.config.ts
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });

export default defineConfig({
  // ... config
});
# .gitignore
.env.test
.env.local
.auth/

16. Never Hardcode Credentials

// ❌ BAD: Credentials in test code
await page.getByLabel('Email').fill('admin@company.com');
await page.getByLabel('Password').fill('P@ssw0rd!');

// ✅ GOOD: Read from environment
await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);

Anti-Patterns

❌ Don't Login Through UI in Every Test

// ❌ BAD: 50 tests × 3-second login = 2.5 minutes wasted
test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@test.com');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
});

// ✅ GOOD: Login once with setup project, reuse storageState
// (See sections 1-2 above)

❌ Don't Share Auth State Between Parallel Workers

// ❌ BAD: Multiple workers writing to same file
setup('login', async ({ page }) => {
  await page.context().storageState({ path: 'auth.json' }); // Race condition!
});

// ✅ GOOD: Each role gets its own file, setup runs once before workers start

❌ Don't Store Tokens in Test Code

// ❌ BAD: Token in source code
const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';

// ✅ GOOD: Token generated dynamically in setup
setup('get auth token', async ({ request }) => {
  const resp = await request.post('/api/auth/login', { data: credentials });
  const { token } = await resp.json();
  // Use token via fixture, not hardcoded
});

Quick Reference

Scenario Approach
Standard login Setup project + storageState
Multiple roles Multiple setup steps + role-specific state files
2FA / TOTP otplib to generate codes in setup
API-heavy app API login (skip UI) + set token in localStorage
OAuth / SSO Mock the OAuth provider with page.route()
Public pages storageState: { cookies: [], origins: [] }
Session expiry Auto-fixture to check + re-auth if needed
CI/CD Env vars from secrets manager

Related Skills

Install via CLI
npx skills add https://github.com/AbhiRKeesara/test-automation-skills --skill authentication-testing
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
AbhiRKeesara
AbhiRKeesara Explore all skills →