| 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
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');
await page.goto('/profile');
});
test('view profile', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
});
Storage State Pattern
1. Save Auth State with Setup Project
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['auth-setup'],
},
],
});
2. Auth Setup File
import { test as setup, expect } from '@playwright/test';
const USER_AUTH_FILE = '.auth/user.json';
setup('authenticate as standard user', async ({ page }) => {
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 expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: USER_AUTH_FILE });
});
3. Add .auth to .gitignore
.auth/
Multi-Role Authentication
4. Different Roles with Different Storage States
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' });
});
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
test.use({ storageState: '.auth/admin.json' });
test('admin can delete users', async ({ page }) => {
await page.goto('/admin/users');
});
6. No Auth for Specific Tests
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
import { test as setup } from '@playwright/test';
import { authenticator } from 'otplib';
setup('authenticate with 2FA', async ({ page }) => {
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 expect(page.getByText('Enter verification code')).toBeVisible();
const secret = process.env.TOTP_SECRET!;
const totpCode = authenticator.generate(secret);
await page.getByLabel('Verification code').fill(totpCode);
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/user-2fa.json' });
});
8. Handle TOTP Timing Issues
import { authenticator } from 'otplib';
function getValidTotpCode(secret: string): string {
const timeRemaining = authenticator.timeRemaining();
if (timeRemaining < 5) {
const waitMs = (timeRemaining + 1) * 1000;
const start = Date.now();
while (Date.now() - start < waitMs) {
}
}
return authenticator.generate(secret);
}
API-Based Authentication (Faster)
9. Skip UI Login — Authenticate via API
import { test as setup } from '@playwright/test';
setup('authenticate via API', async ({ request, page }) => {
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();
await page.goto('/');
await page.evaluate((authToken) => {
localStorage.setItem('auth_token', authToken);
}, token);
await page.context().storageState({ path: '.auth/user.json' });
});
10. Cookie-Based API Auth
setup('authenticate via API with cookies', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL!,
password: process.env.TEST_USER_PASSWORD!,
},
});
await request.storageState({ path: '.auth/user.json' });
});
Session Management
11. Handle Session Expiry
import { test as base } from '@playwright/test';
export const test = base.extend({
ensureAuthenticated: [async ({ page }, use) => {
const response = await page.request.get('/api/auth/me');
if (response.status() === 401) {
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');
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await expect(page).toHaveURL('/login');
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
13. Test Session Timeout
test('expired session redirects to login', async ({ browser }) => {
const context = await browser.newContext({
storageState: {
cookies: [{
name: 'session',
value: 'expired-token-value',
domain: 'localhost',
path: '/',
expires: Math.floor(Date.now() / 1000) - 3600,
httpOnly: true,
secure: false,
sameSite: 'Lax',
}],
origins: [],
},
});
const page = await context.newPage();
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
await context.close();
});
OAuth / SSO Testing
14. Mock OAuth Provider
test('OAuth login flow', async ({ page }) => {
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')!;
await route.fulfill({
status: 302,
headers: {
location: `${redirectUri}?code=mock-auth-code&state=${state}`,
},
});
});
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,
}),
});
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await expect(page).toHaveURL('/dashboard');
});
Credential Management
15. Environment Variables (Required)
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
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
export default defineConfig({
});
.env.test
.env.local
.auth/
16. Never Hardcode Credentials
await page.getByLabel('Email').fill('admin@company.com');
await page.getByLabel('Password').fill('P@ssw0rd!');
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
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();
});
❌ Don't Share Auth State Between Parallel Workers
setup('login', async ({ page }) => {
await page.context().storageState({ path: 'auth.json' });
});
❌ Don't Store Tokens in Test Code
const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
setup('get auth token', async ({ request }) => {
const resp = await request.post('/api/auth/login', { data: credentials });
const { token } = await resp.json();
});
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