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...
page.();
});
(, ({ page }) => {
page.();
(page.(, { : })).();
});
await
goto
'/profile'
// ✅ GOOD: Auth state saved once, reused via storageState
test
'view profile'
async
// Already authenticated via storageState in config!
// 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');
awaitexpect(page.getByLabel('Email')).toBeVisible();
});
Two-Factor Authentication (2FA / TOTP)
7. TOTP with otplib
npm install --save-dev otplib
// tests/auth-2fa.setup.tsimport { test as setup } from'@playwright/test';
import { authenticator } from'otplib';
setup('authenticate with 2FA', async ({ page }) => {
// Step 1: Standard loginawait 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 promptawaitexpect(page.getByText('Enter verification code')).toBeVisible();
// Step 3: Generate TOTP code from secretconst secret = process.env.TOTP_SECRET!;
const totpCode = authenticator.generate(secret);
// Step 4: Enter the TOTP codeawait page.getByLabel('Verification code').fill(totpCode);
await page.getByRole('button', { name: 'Verify' }).click();
// Step 5: Wait for auth to completeawait page.waitForURL('/dashboard');
// Step 6: Save stateawait 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 submissionfunctiongetValidTotpCode(secret: string): string {
const timeRemaining = authenticator.timeRemaining();
// If less than 5 seconds remaining, wait for next windowif (timeRemaining < 5) {
const waitMs = (timeRemaining + 1) * 1000;
// Use synchronous delay to wait for next TOTP windowconst 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.tsimport { 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 contextawait page.goto('/');
await page.evaluate((authToken) => {
localStorage.setItem('auth_token', authToken);
}, token);
// Save the stateawait 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 headersconst 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 cookiesawait request.storageState({ path: '.auth/user.json' });
});
Session Management
11. Handle Session Expiry
// fixtures/auth-fixtures.tsimport { test as base } from'@playwright/test';
exportconst test = base.extend({
// Auto-fixture: check session validity before each testensureAuthenticated: [async ({ page }, use) => {
// Check if session is still validconst response = await page.request.get('/api/auth/me');
if (response.status() === 401) {
// Session expired — re-authenticateconsole.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');
}
awaituse();
}, { auto: true }],
});
// ❌ BAD: Multiple workers writing to same filesetup('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 codeconstAUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
// ✅ GOOD: Token generated dynamically in setupsetup('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
});