.agents/skills/playwright-best-practices/advanced/authentication.md
When to use: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.
// Storage state reuse — the #1 pattern for fast auth
await page.goto('/login')
await page.getByLabel('Username').fill('[email protected]')
await page.getByLabel('Password').fill('secretPass123')
await page.getByRole('button', {name: 'Log in'}).click()
await page.context().storageState({path: '.auth/session.json'})
// Reuse in config — every test starts authenticated
{
use: {
storageState: '.auth/session.json'
}
}
// API login — skip the UI entirely
const context = await browser.newContext()
const response = await context.request.post('/api/auth/login', {
data: {email: '[email protected]', password: 'secretPass123'},
})
await context.storageState({path: '.auth/session.json'})
Use when: You need authenticated tests and want to avoid logging in before every test. Avoid when: Tests require completely fresh sessions, or you are testing the login flow itself.
storageState serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
// scripts/generate-auth.ts — run once to generate the state file
import {chromium} from '@playwright/test'
async function generateAuthState() {
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('http://localhost:4000/login')
await page.getByLabel('Username').fill('[email protected]')
await page.getByLabel('Password').fill('secretPass123')
await page.getByRole('button', {name: 'Log in'}).click()
await page.waitForURL('/home')
await context.storageState({path: '.auth/session.json'})
await browser.close()
}
generateAuthState()
// playwright.config.ts — load saved state for all tests
import {defineConfig} from '@playwright/test'
export default defineConfig({
use: {
baseURL: 'http://localhost:4000',
storageState: '.auth/session.json',
},
})
// tests/home.spec.ts — test starts already logged in
import {test, expect} from '@playwright/test'
test('authenticated user sees home page', async ({page}) => {
await page.goto('/home')
await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()
})
Use when: You want to authenticate once before the entire test suite runs. Avoid when: Different tests need different users, or your tokens expire faster than your suite runs.
// global-setup.ts
import {chromium, type FullConfig} from '@playwright/test'
async function globalSetup(config: FullConfig) {
const {baseURL} = config.projects[0].use
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.goto(`${baseURL}/login`)
await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
await page.getByRole('button', {name: 'Log in'}).click()
await page.waitForURL('**/home')
await context.storageState({path: '.auth/session.json'})
await browser.close()
}
export default globalSetup
// playwright.config.ts
import {defineConfig} from '@playwright/test'
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
baseURL: 'http://localhost:4000',
storageState: '.auth/session.json',
},
})
Add .auth/ to .gitignore. Auth state files contain session tokens and should never be committed.
Use when: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state. Avoid when: Tests are read-only and a modifying shared session is safe, you can use a single shared account.
Sharded runs:
parallelIndexresets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g.,worker-${SHARD_INDEX}-${parallelIndex}@example.com) by passing aSHARD_INDEXenvironment variable from your CI matrix.
// fixtures/auth.ts
import {test as base, type BrowserContext} from '@playwright/test'
type AuthFixtures = {
authenticatedContext: BrowserContext
}
export const test = base.extend<{}, AuthFixtures>({
authenticatedContext: [
async ({browser}, use) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/login')
await page.getByLabel('Username').fill(`worker-${test.info().parallelIndex}@example.com`)
await page.getByLabel('Password').fill('secretPass123')
await page.getByRole('button', {name: 'Log in'}).click()
await page.waitForURL('/home')
await page.close()
await use(context)
await context.close()
},
{scope: 'worker'},
],
})
export {expect} from '@playwright/test'
// tests/settings.spec.ts
import {test, expect} from '../fixtures/auth'
test('update display name', async ({authenticatedContext}) => {
const page = await authenticatedContext.newPage()
await page.goto('/settings/profile')
await page.getByLabel('Display name').fill('Updated Name')
await page.getByRole('button', {name: 'Save'}).click()
await expect(page.getByText('Profile saved')).toBeVisible()
})
Use when: Your app has role-based access control and you need to test different permission levels. Avoid when: Your app has a single user role.
// global-setup.ts — authenticate all roles
import {chromium, type FullConfig} from '@playwright/test'
const accounts = [
{
role: 'admin',
email: '[email protected]',
password: process.env.ADMIN_PASSWORD!,
},
{
role: 'member',
email: '[email protected]',
password: process.env.MEMBER_PASSWORD!,
},
{
role: 'guest',
email: '[email protected]',
password: process.env.GUEST_PASSWORD!,
},
]
async function globalSetup(config: FullConfig) {
const {baseURL} = config.projects[0].use
for (const {role, email, password} of accounts) {
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.goto(`${baseURL}/login`)
await page.getByLabel('Username').fill(email)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', {name: 'Log in'}).click()
await page.waitForURL('**/home')
await context.storageState({path: `.auth/${role}.json`})
await browser.close()
}
}
export default globalSetup
// playwright.config.ts — one project per role
import {defineConfig} from '@playwright/test'
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
projects: [
{
name: 'admin',
use: {storageState: '.auth/admin.json'},
testMatch: '**/*.admin.spec.ts',
},
{
name: 'member',
use: {storageState: '.auth/member.json'},
testMatch: '**/*.member.spec.ts',
},
{
name: 'guest',
use: {storageState: '.auth/guest.json'},
testMatch: '**/*.guest.spec.ts',
},
{
name: 'anonymous',
use: {storageState: {cookies: [], origins: []}},
testMatch: '**/*.anon.spec.ts',
},
],
})
// tests/admin-panel.admin.spec.ts
import {test, expect} from '@playwright/test'
test('admin can access user management', async ({page}) => {
await page.goto('/admin/users')
await expect(page.getByRole('heading', {name: 'User Management'})).toBeVisible()
await expect(page.getByRole('button', {name: 'Remove user'})).toBeEnabled()
})
// tests/admin-panel.guest.spec.ts
import {test, expect} from '@playwright/test'
test('guest cannot access admin panel', async ({page}) => {
await page.goto('/admin/users')
await expect(page.getByText('Access denied')).toBeVisible()
})
Alternative: Use a fixture that accepts a role parameter when you need role switching within a single spec file.
// fixtures/auth.ts — role-based fixture
import {test as base, type Page} from '@playwright/test'
import fs from 'fs'
type RoleFixtures = {
loginAs: (role: 'admin' | 'member' | 'guest') => Promise<Page>
}
export const test = base.extend<RoleFixtures>({
loginAs: async ({browser}, use) => {
const pages: Page[] = []
await use(async (role) => {
const statePath = `.auth/${role}.json`
if (!fs.existsSync(statePath)) {
throw new Error(`Auth state for role "${role}" not found at ${statePath}`)
}
const context = await browser.newContext({storageState: statePath})
const page = await context.newPage()
pages.push(page)
return page
})
for (const page of pages) {
await page.context().close()
}
},
})
export {expect} from '@playwright/test'
// tests/role-comparison.spec.ts
import {test, expect} from '../fixtures/auth'
test('admin sees remove button, guest does not', async ({loginAs}) => {
const adminPage = await loginAs('admin')
await adminPage.goto('/admin/users')
await expect(adminPage.getByRole('button', {name: 'Remove user'})).toBeVisible()
const guestPage = await loginAs('guest')
await guestPage.goto('/admin/users')
await expect(guestPage.getByText('Access denied')).toBeVisible()
})
Use when: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests. Avoid when: You have a dedicated test tenant on the OAuth provider.
A typical OAuth flow works like this:
https://accounts.provider.com/authorize?...http://localhost:4000/auth/callback?code=ABC&state=XYZ)code for an access token, creates a session, and redirects the user to a logged-in pageIn tests you can short-circuit step 2 with page.route(): intercept the outbound request to the provider and respond with a 302 redirect straight to your callback route, supplying a mock code and state. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.
For cases where you want to skip the browser redirect entirely, a second approach calls a test-only API endpoint that creates the session server-side and returns the session cookie directly.
// tests/oauth-login.spec.ts — mock the callback route
import {test, expect} from '@playwright/test'
test('login via mocked OAuth flow', async ({page}) => {
await page.route('https://accounts.provider.com/**', async (route) => {
const callbackUrl = new URL('http://localhost:4000/auth/callback')
callbackUrl.searchParams.set('code', 'mock-auth-code-xyz')
callbackUrl.searchParams.set('state', 'expected-state-value')
await route.fulfill({
status: 302,
headers: {location: callbackUrl.toString()},
})
})
await page.goto('/login')
await page.getByRole('button', {name: 'Sign in with Provider'}).click()
await page.waitForURL('/home')
await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()
})
// tests/oauth-login.spec.ts — API-based session injection
import {test, expect} from '@playwright/test'
test('bypass OAuth entirely via API session injection', async ({page}) => {
// Call a test-only endpoint that creates a session without OAuth
const response = await page.request.post('/api/test/create-session', {
data: {
email: '[email protected]',
provider: 'provider',
role: 'member',
},
})
expect(response.ok()).toBeTruthy()
await page.context().storageState({path: '.auth/oauth-user.json'})
await page.goto('/home')
await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()
})
Backend requirement: Your backend must expose a test-only session creation endpoint (guarded by NODE_ENV=test) or accept a known test OAuth code.
Use when: Your app requires two-factor authentication (TOTP, SMS, email codes). Avoid when: MFA is optional and you can disable it for test accounts.
Strategy 1: Generate real TOTP codes from a shared secret.
// helpers/totp.ts
import * as OTPAuth from 'otpauth'
export function generateTOTP(secret: string): string {
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
digits: 6,
period: 30,
algorithm: 'SHA1',
})
return totp.generate()
}
// tests/mfa-login.spec.ts
import {test, expect} from '@playwright/test'
import {generateTOTP} from '../helpers/totp'
test('login with TOTP two-factor auth', async ({page}) => {
await page.goto('/login')
await page.getByLabel('Username').fill('[email protected]')
await page.getByLabel('Password').fill('secretPass123')
await page.getByRole('button', {name: 'Log in'}).click()
await expect(page.getByText('Enter your authentication code')).toBeVisible()
const code = generateTOTP(process.env.MFA_TOTP_SECRET!)
await page.getByLabel('Authentication code').fill(code)
await page.getByRole('button', {name: 'Verify'}).click()
await page.waitForURL('/home')
await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()
})
Strategy 2: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., 000000) when NODE_ENV=test.
Strategy 3: Disable MFA for test accounts at the infrastructure level.
Use when: Your tokens expire during long test runs. Avoid when: Your test suite runs quickly and tokens outlast the entire run.
// fixtures/auth-with-refresh.ts
import {test as base, type BrowserContext} from '@playwright/test'
import fs from 'fs'
type AuthFixtures = {
authenticatedPage: import('@playwright/test').Page
}
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({browser}, use) => {
const statePath = '.auth/session.json'
let context: BrowserContext
if (fs.existsSync(statePath)) {
context = await browser.newContext({storageState: statePath})
const page = await context.newPage()
const response = await page.request.get('/api/auth/me')
if (response.ok()) {
await use(page)
await context.close()
return
}
await context.close()
}
context = await browser.newContext()
const page = await context.newPage()
await page.goto('/login')
await page.getByLabel('Username').fill(process.env.TEST_USER_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
await page.getByRole('button', {name: 'Log in'}).click()
await page.waitForURL('/home')
await context.storageState({path: statePath})
await use(page)
await context.close()
},
})
export {expect} from '@playwright/test'
Use when: Multiple test files need to log in and you want consistent, maintainable login logic.
Avoid when: You use storageState everywhere and never navigate through the login UI in tests.
// page-objects/LoginPage.ts
import {type Page, type Locator, expect} from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly usernameInput: Locator
readonly passwordInput: Locator
readonly loginButton: Locator
readonly errorMessage: Locator
readonly forgotPasswordLink: Locator
constructor(page: Page) {
this.page = page
this.usernameInput = page.getByLabel('Username')
this.passwordInput = page.getByLabel('Password')
this.loginButton = page.getByRole('button', {name: 'Log in'})
this.errorMessage = page.getByRole('alert')
this.forgotPasswordLink = page.getByRole('link', {
name: 'Forgot password',
})
}
async goto() {
await this.page.goto('/login')
await expect(this.loginButton).toBeVisible()
}
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.loginButton.click()
}
async loginAndWaitForHome(username: string, password: string) {
await this.login(username, password)
await this.page.waitForURL('/home')
}
async expectError(message: string | RegExp) {
await expect(this.errorMessage).toContainText(message)
}
async expectFieldError(field: 'username' | 'password', message: string) {
const input = field === 'username' ? this.usernameInput : this.passwordInput
await expect(input).toHaveAttribute('aria-invalid', 'true')
const errorId = await input.getAttribute('aria-describedby')
if (errorId) {
await expect(this.page.locator(`#${errorId}`)).toContainText(message)
}
}
}
// tests/login.spec.ts
import {test, expect} from '@playwright/test'
import {LoginPage} from '../page-objects/LoginPage'
test.use({storageState: {cookies: [], origins: []}})
test.describe('login page', () => {
let loginPage: LoginPage
test.beforeEach(async ({page}) => {
loginPage = new LoginPage(page)
await loginPage.goto()
})
test('successful login redirects to home', async ({page}) => {
await loginPage.loginAndWaitForHome('[email protected]', 'secretPass123')
await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()
})
test('wrong password shows error', async () => {
await loginPage.login('[email protected]', 'wrong-password')
await loginPage.expectError('Invalid username or password')
})
test('empty fields show validation errors', async () => {
await loginPage.loginButton.click()
await loginPage.expectFieldError('username', 'Username is required')
})
test('forgot password link navigates correctly', async ({page}) => {
await loginPage.forgotPasswordLink.click()
await page.waitForURL('/forgot-password')
await expect(page.getByRole('heading', {name: 'Reset password'})).toBeVisible()
})
})
Use when: You want the fastest possible authentication without any browser interaction. Avoid when: You are specifically testing the login UI.
API login is typically 5-10x faster than UI login.
// global-setup.ts — API-based login (fastest)
import {request, type FullConfig} from '@playwright/test'
async function globalSetup(config: FullConfig) {
const {baseURL} = config.projects[0].use
const requestContext = await request.newContext({baseURL})
const response = await requestContext.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL!,
password: process.env.TEST_USER_PASSWORD!,
},
})
if (!response.ok()) {
throw new Error(`API login failed: ${response.status()} ${await response.text()}`)
}
await requestContext.storageState({path: '.auth/session.json'})
await requestContext.dispose()
}
export default globalSetup
// fixtures/api-auth.ts — fixture version for per-test authentication
import {test as base} from '@playwright/test'
export const test = base.extend({
authenticatedPage: async ({browser, playwright}, use) => {
const apiContext = await playwright.request.newContext({
baseURL: 'http://localhost:4000',
})
await apiContext.post('/api/auth/login', {
data: {
email: '[email protected]',
password: 'secretPass123',
},
})
const state = await apiContext.storageState()
const context = await browser.newContext({storageState: state})
const page = await context.newPage()
await use(page)
await context.close()
await apiContext.dispose()
},
})
export {expect} from '@playwright/test'
Use when: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users. Avoid when: The test requires a logged-in user.
When your config sets a default storageState, you must explicitly clear it for unauthenticated tests.
// tests/public-pages.spec.ts
import {test, expect} from '@playwright/test'
test.use({storageState: {cookies: [], origins: []}})
test.describe('unauthenticated access', () => {
test('homepage is accessible without login', async ({page}) => {
await page.goto('/')
await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()
await expect(page.getByRole('link', {name: 'Log in'})).toBeVisible()
})
test('protected route redirects to login', async ({page}) => {
await page.goto('/home')
await page.waitForURL('**/login**')
expect(page.url()).toContain('redirect=%2Fhome')
})
test('expired session shows re-login prompt', async ({page, context}) => {
await page.goto('/home')
await context.clearCookies()
await page.goto('/settings')
await page.waitForURL('**/login**')
await expect(page.getByText('Your session has expired')).toBeVisible()
})
test('signup flow creates account', async ({page}) => {
await page.goto('/signup')
await page.getByLabel('Name').fill('New User')
await page.getByLabel('Email').fill(`test-${Date.now()}@example.com`)
await page.getByLabel('Password', {exact: true}).fill('secretPass123')
await page.getByLabel('Confirm password').fill('secretPass123')
await page.getByRole('button', {name: 'Create account'}).click()
await page.waitForURL('/onboarding')
await expect(page.getByText('Welcome, New User')).toBeVisible()
})
})
| Scenario | Approach | Speed | Isolation | When to Choose |
|---|---|---|---|---|
| Most tests need auth | Global setup + storageState | Fastest | Shared session | Default for nearly every project |
| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data |
| Multiple user roles | Per-project storageState | Fastest | Per role | App has admin/member/guest roles |
| Testing the login page | No storageState | N/A | Full | Use test.use({ storageState: { cookies: [], origins: [] } }) |
| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI |
| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass |
| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use |
| Single test needs different user | loginAs(role) fixture | Moderate | Per call | Rare: prefer per-project roles |
| API-first app (no login UI) | API login via request.post() | Fastest | Per test | No browser needed for auth |
Need to test the login page itself?
├── Yes → UI login with LoginPage POM, no storageState
└── No → Do you have a login API endpoint?
├── Yes → API login in global setup, save storageState (fastest)
└── No → UI login in global setup, save storageState
└── Tokens expire quickly?
├── Yes → Add session refresh fixture
└── No → Standard storageState reuse is fine
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Log in via UI before every test | Adds 2-5 seconds per test | Use storageState to skip login entirely |
| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with { scope: 'worker' } |
| Hardcode credentials in test files | Security risk | Use environment variables and .env files |
| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture |
| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection |
Use page.waitForTimeout(2000) after login | Arbitrary delay | await page.waitForURL('/home') or await expect(heading).toBeVisible() |
Store .auth/*.json files in git | Tokens in version control | Add .auth/ to .gitignore |
| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role |
Use browser.newContext() without storageState for authenticated tests | Every context starts unauthenticated | Pass storageState when creating the context |
| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test |
Cause: The login page redirected unexpectedly, or the browser closed before storageState() was called.
Fix:
await page.waitForURL() after the login actionbaseURL in your config matches the actual server URL and protocolconst response = await page.waitForResponse('**/api/auth/**')
if (!response.ok()) {
throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)
}
Cause: The session token saved in storageState has expired.
Fix:
storageState file is empty or contains no cookiesCause: storageState() was called before the login response set cookies.
Fix:
await page.waitForURL('/home')const cookies = await context.cookies()
if (cookies.length === 0) {
throw new Error('No cookies found after login')
}
await context.storageState({path: '.auth/session.json'})
Cause: Some auth flows set cookies with SameSite=Strict or use browser-specific cookie behavior.
Fix:
SameSite=None; Secure cookies that require HTTPS:projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },
},
],
Cause: Multiple workers share the same test account and one worker's actions affect others.
Fix:
worker-${test.info().parallelIndex}@example.comCause: page.route() was registered after the navigation that triggers the OAuth redirect.
Fix:
page.route() before page.goto()page.on('request', (req) => {
if (req.url().includes('oauth') || req.url().includes('accounts.provider')) {
console.log('OAuth request:', req.url())
}
})
storageState, projects, and global setup configuration