# Authentication Testing

> **When to use**: Any app that has login, session management, or protected routes. Authentication is the most common source of slow test suites -- get this right and your entire suite speeds up.
> **Prerequisites**: [core/configuration.md](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)

## Quick Reference

```typescript
// Storage state reuse — the #1 pattern for fast auth
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()
await page.context().storageState({ path: ".auth/user.json" })

// Reuse in config — every test starts authenticated, zero login overhead
{
  use: {
    storageState: ".auth/user.json"
  }
}

// API login — skip the UI entirely
const context = await browser.newContext()
const response = await context.request.post("/api/auth/login", {
  data: { email: "user@test.com", password: "password" },
})
await context.storageState({ path: ".auth/user.json" })
```

## Patterns

### Storage State Reuse

**Use when**: You need authenticated tests and want to avoid logging in before every test. This is the default pattern for nearly every project.
**Avoid when**: Tests require completely fresh sessions with no prior state, or you are testing the login flow itself.

Playwright's `storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.

**TypeScript**

```typescript
// scripts/save-auth-state.ts — run once to generate the state file
import { chromium } from "@playwright/test"

async function saveAuthState() {
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto("http://localhost:3000/login")
  await page.getByLabel("Email").fill("user@test.com")
  await page.getByLabel("Password").fill("s3cure!Pass")
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("/dashboard")

  // Save cookies + localStorage to a file
  await context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

saveAuthState()
```

```typescript
// playwright.config.ts — load saved state for all tests
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})
```

```typescript
// tests/dashboard.spec.ts — test starts already logged in
import { test, expect } from "@playwright/test"

test("authenticated user sees dashboard", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  // No login step needed — storageState handles it
})
```

**JavaScript**

```javascript
// scripts/save-auth-state.js
const { chromium } = require("@playwright/test")

async function saveAuthState() {
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto("http://localhost:3000/login")
  await page.getByLabel("Email").fill("user@test.com")
  await page.getByLabel("Password").fill("s3cure!Pass")
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("/dashboard")

  await context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

saveAuthState()
```

```javascript
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})
```

### Global Setup Authentication

**Use when**: You want to authenticate once before the entire test suite runs, then reuse that session everywhere. The standard Playwright-recommended approach.
**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.

Global setup runs once per `npx playwright test` invocation. It logs in, saves the state, and every test project that references that state file starts authenticated.

**TypeScript**

```typescript
// 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("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 context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

export default globalSetup
```

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  globalSetup: require.resolve("./global-setup"),
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})
```

**JavaScript**

```javascript
// global-setup.js
const { chromium } = require("@playwright/test")

async function globalSetup(config) {
  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("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 context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

module.exports = globalSetup
```

```javascript
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  globalSetup: require.resolve("./global-setup"),
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})
```

**Important**: Add `.auth/` to your `.gitignore`. Auth state files contain session tokens and should never be committed.

### Per-Worker Authentication

**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions. Essential when tests modify user state (profile updates, settings changes, data mutations).
**Avoid when**: Tests are read-only and a shared session is safe, or you only run tests serially.

Worker-scoped fixtures run once per worker process, not once per test. This gives each parallel worker its own isolated session.

**TypeScript**

```typescript
// 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("Email").fill(`worker-${test.info().parallelIndex}@test.com`)
      await page.getByLabel("Password").fill("password")
      await page.getByRole("button", { name: "Sign in" }).click()
      await page.waitForURL("/dashboard")
      await page.close()

      await use(context)
      await context.close()
    },
    { scope: "worker" },
  ],
})

export { expect } from "@playwright/test"
```

```typescript
// tests/profile.spec.ts
import { test, expect } from "../fixtures/auth"

test("update profile 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 updated")).toBeVisible()
})
```

**JavaScript**

```javascript
// fixtures/auth.js
const { test: base } = require("@playwright/test")

const test = base.extend({
  authenticatedContext: [
    async ({ browser }, use) => {
      const context = await browser.newContext()
      const page = await context.newPage()

      await page.goto("/login")
      await page.getByLabel("Email").fill(`worker-${test.info().parallelIndex}@test.com`)
      await page.getByLabel("Password").fill("password")
      await page.getByRole("button", { name: "Sign in" }).click()
      await page.waitForURL("/dashboard")
      await page.close()

      await use(context)
      await context.close()
    },
    { scope: "worker" },
  ],
})

module.exports = { test, expect: require("@playwright/test").expect }
```

### Multiple Roles

**Use when**: Your app has role-based access control and you need to test that admins, regular users, and viewers see different things.
**Avoid when**: Your app has a single user role.

Use separate Playwright projects with different storage states, one per role. This is cleaner than switching roles within tests.

**TypeScript**

```typescript
// global-setup.ts — authenticate all roles
import { chromium, type FullConfig } from "@playwright/test"

const users = [
  { role: "admin", email: "admin@test.com", password: process.env.ADMIN_PASSWORD! },
  { role: "user", email: "user@test.com", password: process.env.USER_PASSWORD! },
  { role: "viewer", email: "viewer@test.com", password: process.env.VIEWER_PASSWORD! },
]

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use

  for (const { role, email, password } of users) {
    const browser = await chromium.launch()
    const context = await browser.newContext()
    const page = await context.newPage()

    await page.goto(`${baseURL}/login`)
    await page.getByLabel("Email").fill(email)
    await page.getByLabel("Password").fill(password)
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("**/dashboard")

    await context.storageState({ path: `.auth/${role}.json` })
    await browser.close()
  }
}

export default globalSetup
```

```typescript
// 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: "user",
      use: { storageState: ".auth/user.json" },
      testMatch: "**/*.user.spec.ts",
    },
    {
      name: "viewer",
      use: { storageState: ".auth/viewer.json" },
      testMatch: "**/*.viewer.spec.ts",
    },
    {
      name: "unauthenticated",
      use: { storageState: { cookies: [], origins: [] } },
      testMatch: "**/*.anon.spec.ts",
    },
  ],
})
```

```typescript
// 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: "Delete user" })).toBeEnabled()
})
```

```typescript
// tests/admin-panel.viewer.spec.ts
import { test, expect } from "@playwright/test"

test("viewer cannot access admin panel", async ({ page }) => {
  await page.goto("/admin/users")
  // Should redirect to forbidden or dashboard
  await expect(page.getByText("Access denied")).toBeVisible()
})
```

**JavaScript**

```javascript
// global-setup.js
const { chromium } = require("@playwright/test")

const users = [
  { role: "admin", email: "admin@test.com", password: process.env.ADMIN_PASSWORD },
  { role: "user", email: "user@test.com", password: process.env.USER_PASSWORD },
  { role: "viewer", email: "viewer@test.com", password: process.env.VIEWER_PASSWORD },
]

async function globalSetup(config) {
  const { baseURL } = config.projects[0].use

  for (const { role, email, password } of users) {
    const browser = await chromium.launch()
    const context = await browser.newContext()
    const page = await context.newPage()

    await page.goto(`${baseURL}/login`)
    await page.getByLabel("Email").fill(email)
    await page.getByLabel("Password").fill(password)
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("**/dashboard")

    await context.storageState({ path: `.auth/${role}.json` })
    await browser.close()
  }
}

module.exports = globalSetup
```

```javascript
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  globalSetup: require.resolve("./global-setup"),
  projects: [
    {
      name: "admin",
      use: { storageState: ".auth/admin.json" },
      testMatch: "**/*.admin.spec.js",
    },
    {
      name: "user",
      use: { storageState: ".auth/user.json" },
      testMatch: "**/*.user.spec.js",
    },
    {
      name: "viewer",
      use: { storageState: ".auth/viewer.json" },
      testMatch: "**/*.viewer.spec.js",
    },
    {
      name: "unauthenticated",
      use: { storageState: { cookies: [], origins: [] } },
      testMatch: "**/*.anon.spec.js",
    },
  ],
})
```

**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.

```typescript
// fixtures/auth.ts — role-based fixture
import { test as base, type Page } from "@playwright/test"
import fs from "fs"

type RoleFixtures = {
  loginAs: (role: "admin" | "user" | "viewer") => 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}. Run global setup first.`
        )
      }
      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"
```

```typescript
// tests/role-comparison.spec.ts
import { test, expect } from "../fixtures/auth"

test("admin sees delete button, viewer does not", async ({ loginAs }) => {
  const adminPage = await loginAs("admin")
  await adminPage.goto("/admin/users")
  await expect(adminPage.getByRole("button", { name: "Delete user" })).toBeVisible()

  const viewerPage = await loginAs("viewer")
  await viewerPage.goto("/admin/users")
  await expect(viewerPage.getByText("Access denied")).toBeVisible()
})
```

### OAuth/SSO Mocking

**Use when**: Your app authenticates via a third-party OAuth provider (Google, GitHub, Okta, Auth0) and you cannot or should not hit the real provider in tests.
**Avoid when**: You have a dedicated test tenant on the OAuth provider and want true end-to-end coverage of the OAuth flow.

The strategy: intercept the OAuth callback route and inject a valid session directly, bypassing the provider entirely.

**TypeScript**

```typescript
// fixtures/oauth-mock.ts
import { test as base } from "@playwright/test"

export const test = base.extend({
  // Mock OAuth by intercepting the callback and injecting session cookies
  page: async ({ page }, use) => {
    // Intercept the OAuth redirect to the provider
    await page.route("**/auth/callback**", async (route) => {
      // Instead of going to Google/GitHub, call your own backend
      // with a test token that your backend recognizes in test mode
      const url = new URL(route.request().url())
      url.searchParams.set("code", "test-oauth-code")
      url.searchParams.set("state", url.searchParams.get("state") || "")

      await route.continue({ url: url.toString() })
    })

    await use(page)
  },
})

export { expect } from "@playwright/test"
```

```typescript
// tests/oauth-login.spec.ts — approach 1: mock the callback route
import { test, expect } from "@playwright/test"

test("login via mocked OAuth flow", async ({ page }) => {
  // Intercept the redirect to the OAuth provider
  await page.route("https://accounts.google.com/**", async (route) => {
    // Redirect back to your app's callback with a fake auth code
    const callbackUrl = new URL("http://localhost:3000/auth/callback")
    callbackUrl.searchParams.set("code", "mock-auth-code-12345")
    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 Google" }).click()

  // Your backend must accept "mock-auth-code-12345" in test mode
  // and exchange it for a real session
  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

```typescript
// tests/oauth-login.spec.ts — approach 2: API-based session injection
import { test, expect } from "@playwright/test"

test("bypass OAuth entirely via API session injection", async ({ page, request }) => {
  // Call a test-only endpoint that creates a session without OAuth
  // Your backend exposes this only when NODE_ENV=test
  const response = await request.post("/api/test/create-session", {
    data: {
      email: "oauth-user@test.com",
      provider: "google",
      role: "user",
    },
  })
  expect(response.ok()).toBeTruthy()

  // The response sets session cookies; storageState captures them
  await page.context().storageState({ path: ".auth/oauth-user.json" })

  // Now navigate — already authenticated
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("login via mocked OAuth flow", async ({ page }) => {
  await page.route("https://accounts.google.com/**", async (route) => {
    const callbackUrl = new URL("http://localhost:3000/auth/callback")
    callbackUrl.searchParams.set("code", "mock-auth-code-12345")
    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 Google" }).click()

  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

```javascript
test("bypass OAuth entirely via API session injection", async ({ page, request }) => {
  const response = await request.post("/api/test/create-session", {
    data: {
      email: "oauth-user@test.com",
      provider: "google",
      role: "user",
    },
  })
  expect(response.ok()).toBeTruthy()

  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).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. Never ship test auth endpoints to production.

### MFA Handling

**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes) and you need to handle it in tests.
**Avoid when**: MFA is optional and you can disable it for test accounts.

**Strategy 1**: Generate real TOTP codes from a shared secret. This is the most reliable approach.

**TypeScript**

```typescript
// 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()
}
```

```typescript
// 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("Email").fill("mfa-user@test.com")
  await page.getByLabel("Password").fill("password")
  await page.getByRole("button", { name: "Sign in" }).click()

  // MFA challenge screen
  await expect(page.getByText("Enter your authentication code")).toBeVisible()

  // Generate a valid TOTP from the test account's secret
  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("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

**JavaScript**

```javascript
// helpers/totp.js
const OTPAuth = require("otpauth")

function generateTOTP(secret) {
  const totp = new OTPAuth.TOTP({
    secret: OTPAuth.Secret.fromBase32(secret),
    digits: 6,
    period: 30,
    algorithm: "SHA1",
  })
  return totp.generate()
}

module.exports = { generateTOTP }
```

```javascript
// tests/mfa-login.spec.js
const { test, expect } = require("@playwright/test")
const { generateTOTP } = require("../helpers/totp")

test("login with TOTP two-factor auth", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("mfa-user@test.com")
  await page.getByLabel("Password").fill("password")
  await page.getByRole("button", { name: "Sign 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("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.

```typescript
// Simpler but less realistic — backend accepts bypass code in test mode
test("login with MFA bypass code", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("mfa-user@test.com")
  await page.getByLabel("Password").fill("password")
  await page.getByRole("button", { name: "Sign in" }).click()

  // Backend accepts "000000" as a valid MFA code in test environment
  await page.getByLabel("Authentication code").fill("000000")
  await page.getByRole("button", { name: "Verify" }).click()

  await page.waitForURL("/dashboard")
})
```

**Strategy 3**: Disable MFA for test accounts at the infrastructure level. Set up dedicated test users with MFA disabled. This is the simplest approach when feasible.

### Session Refresh

**Use when**: Your tokens expire during long test runs or your app uses short-lived JWTs that need refreshing.
**Avoid when**: Your test suite runs in under a few minutes and tokens outlast the entire run.

**TypeScript**

```typescript
// 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/user.json"

    // Check if the stored session is still valid
    let context: BrowserContext
    if (fs.existsSync(statePath)) {
      context = await browser.newContext({ storageState: statePath })
      const page = await context.newPage()

      // Quick health check — does the session still work?
      const response = await page.request.get("/api/auth/me")
      if (response.ok()) {
        await use(page)
        await context.close()
        return
      }
      // Session expired — close and re-authenticate
      await context.close()
    }

    // Re-authenticate
    context = await browser.newContext()
    const page = await context.newPage()
    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")

    // Save refreshed state for subsequent tests
    await context.storageState({ path: statePath })

    await use(page)
    await context.close()
  },
})

export { expect } from "@playwright/test"
```

```typescript
// Alternative: intercept token refresh requests to ensure they work
import { test, expect } from "@playwright/test"

test("app refreshes expired token automatically", async ({ page }) => {
  await page.goto("/dashboard")

  // Simulate token expiration by clearing the access token cookie
  await page.context().clearCookies({ name: "access_token" })

  // Next API call should trigger a token refresh via refresh_token cookie
  const refreshPromise = page.waitForResponse("**/api/auth/refresh")
  await page.getByRole("button", { name: "Load data" }).click()
  const refreshResponse = await refreshPromise

  expect(refreshResponse.status()).toBe(200)
  await expect(page.getByTestId("data-table")).toBeVisible()
})
```

**JavaScript**

```javascript
// fixtures/auth-with-refresh.js
const { test: base } = require("@playwright/test")
const fs = require("fs")

const test = base.extend({
  authenticatedPage: async ({ browser }, use) => {
    const statePath = ".auth/user.json"

    if (fs.existsSync(statePath)) {
      const 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()
    }

    const context = await browser.newContext()
    const page = await context.newPage()
    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 context.storageState({ path: statePath })

    await use(page)
    await context.close()
  },
})

module.exports = { test, expect: require("@playwright/test").expect }
```

### Login Page Object

**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic with proper error handling.
**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests (still useful for testing the login page itself).

**TypeScript**

```typescript
// page-objects/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test"

export class LoginPage {
  readonly page: Page
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly signInButton: Locator
  readonly errorMessage: Locator
  readonly forgotPasswordLink: Locator

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByLabel("Email")
    this.passwordInput = page.getByLabel("Password")
    this.signInButton = page.getByRole("button", { name: "Sign in" })
    this.errorMessage = page.getByRole("alert")
    this.forgotPasswordLink = page.getByRole("link", { name: "Forgot password" })
  }

  async goto() {
    await this.page.goto("/login")
    await expect(this.signInButton).toBeVisible()
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.signInButton.click()
  }

  async loginAndWaitForDashboard(email: string, password: string) {
    await this.login(email, password)
    await this.page.waitForURL("/dashboard")
  }

  async expectError(message: string | RegExp) {
    await expect(this.errorMessage).toContainText(message)
  }

  async expectFieldError(field: "email" | "password", message: string) {
    const input = field === "email" ? this.emailInput : this.passwordInput
    await expect(input).toHaveAttribute("aria-invalid", "true")
    // Error message associated with the field
    const errorId = await input.getAttribute("aria-describedby")
    if (errorId) {
      await expect(this.page.locator(`#${errorId}`)).toContainText(message)
    }
  }
}
```

```typescript
// tests/login.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "../page-objects/LoginPage"

// These tests run WITHOUT storageState (unauthenticated)
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 dashboard", async ({ page }) => {
    await loginPage.loginAndWaitForDashboard("user@test.com", "password")
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  })

  test("wrong password shows error", async () => {
    await loginPage.login("user@test.com", "wrong-password")
    await loginPage.expectError("Invalid email or password")
  })

  test("empty fields show validation errors", async () => {
    await loginPage.signInButton.click()
    await loginPage.expectFieldError("email", "Email 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()
  })
})
```

**JavaScript**

```javascript
// page-objects/LoginPage.js
const { expect } = require("@playwright/test")

class LoginPage {
  constructor(page) {
    this.page = page
    this.emailInput = page.getByLabel("Email")
    this.passwordInput = page.getByLabel("Password")
    this.signInButton = page.getByRole("button", { name: "Sign in" })
    this.errorMessage = page.getByRole("alert")
    this.forgotPasswordLink = page.getByRole("link", { name: "Forgot password" })
  }

  async goto() {
    await this.page.goto("/login")
    await expect(this.signInButton).toBeVisible()
  }

  async login(email, password) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.signInButton.click()
  }

  async loginAndWaitForDashboard(email, password) {
    await this.login(email, password)
    await this.page.waitForURL("/dashboard")
  }

  async expectError(message) {
    await expect(this.errorMessage).toContainText(message)
  }

  async expectFieldError(field, message) {
    const input = field === "email" ? this.emailInput : 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)
    }
  }
}

module.exports = { LoginPage }
```

```javascript
// tests/login.spec.js
const { test, expect } = require("@playwright/test")
const { LoginPage } = require("../page-objects/LoginPage")

test.use({ storageState: { cookies: [], origins: [] } })

test.describe("login page", () => {
  let loginPage

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page)
    await loginPage.goto()
  })

  test("successful login redirects to dashboard", async ({ page }) => {
    await loginPage.loginAndWaitForDashboard("user@test.com", "password")
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  })

  test("wrong password shows error", async () => {
    await loginPage.login("user@test.com", "wrong-password")
    await loginPage.expectError("Invalid email or password")
  })

  test("empty fields show validation errors", async () => {
    await loginPage.signInButton.click()
    await loginPage.expectFieldError("email", "Email is required")
  })
})
```

### API-Based Login

**Use when**: You want the fastest possible authentication without any browser interaction. Ideal for generating `storageState` files in global setup or fixtures.
**Avoid when**: You are specifically testing the login UI. Use the Login Page Object pattern instead.

API login is typically 5-10x faster than UI login. Use `request.post()` to hit your auth endpoint directly, then capture the resulting cookies.

**TypeScript**

```typescript
// 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 })

  // Login via API — no browser needed
  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()}`)
  }

  // Save the session cookies from the API response
  await requestContext.storageState({ path: ".auth/user.json" })
  await requestContext.dispose()
}

export default globalSetup
```

```typescript
// 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) => {
    // Create API context and login
    const apiContext = await playwright.request.newContext({
      baseURL: "http://localhost:3000",
    })

    await apiContext.post("/api/auth/login", {
      data: {
        email: "user@test.com",
        password: "password",
      },
    })

    // Transfer API session to browser context
    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"
```

**JavaScript**

```javascript
// global-setup.js — API-based login
const { request } = require("@playwright/test")

async function globalSetup(config) {
  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/user.json" })
  await requestContext.dispose()
}

module.exports = globalSetup
```

```javascript
// fixtures/api-auth.js
const { test: base } = require("@playwright/test")

const test = base.extend({
  authenticatedPage: async ({ browser, playwright }, use) => {
    const apiContext = await playwright.request.newContext({
      baseURL: "http://localhost:3000",
    })

    await apiContext.post("/api/auth/login", {
      data: { email: "user@test.com", password: "password" },
    })

    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()
  },
})

module.exports = { test, expect: require("@playwright/test").expect }
```

### Unauthenticated Tests

**Use when**: Testing the login page, signup flow, password reset, public pages, authentication error handling, 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.

**TypeScript**

```typescript
// tests/public-pages.spec.ts
import { test, expect } from "@playwright/test"

// Override storageState to empty — no cookies, no session
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: "Sign in" })).toBeVisible()
  })

  test("protected route redirects to login", async ({ page }) => {
    await page.goto("/dashboard")
    await page.waitForURL("**/login**")
    // Verify redirect preserves the intended destination
    expect(page.url()).toContain("redirect=%2Fdashboard")
  })

  test("expired session shows re-login prompt", async ({ page, context }) => {
    // Start with a valid session, then invalidate it
    await page.goto("/dashboard")

    // Clear all cookies to simulate session expiration
    await context.clearCookies()

    // Next navigation should detect the invalid session
    await page.goto("/settings")
    await page.waitForURL("**/login**")
    await expect(page.getByText("Your session has expired")).toBeVisible()
  })

  test("login page shows social auth options", async ({ page }) => {
    await page.goto("/login")
    await expect(page.getByRole("button", { name: "Sign in with Google" })).toBeVisible()
    await expect(page.getByRole("button", { name: "Sign in with GitHub" })).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()}@test.com`)
    await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
    await page.getByLabel("Confirm password").fill("s3cure!Pass")
    await page.getByRole("button", { name: "Create account" }).click()

    await page.waitForURL("/onboarding")
    await expect(page.getByText("Welcome, New User")).toBeVisible()
  })
})
```

**JavaScript**

```javascript
// tests/public-pages.spec.js
const { test, expect } = require("@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: "Sign in" })).toBeVisible()
  })

  test("protected route redirects to login", async ({ page }) => {
    await page.goto("/dashboard")
    await page.waitForURL("**/login**")
    expect(page.url()).toContain("redirect=%2Fdashboard")
  })

  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()}@test.com`)
    await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
    await page.getByLabel("Confirm password").fill("s3cure!Pass")
    await page.getByRole("button", { name: "Create account" }).click()

    await page.waitForURL("/onboarding")
    await expect(page.getByText("Welcome, New User")).toBeVisible()
  })
})
```

## Decision Guide

| Scenario                         | Approach                       | Speed    | Isolation      | When to Choose                                                                                      |
| -------------------------------- | ------------------------------ | -------- | -------------- | --------------------------------------------------------------------------------------------------- |
| Most tests need auth             | Global setup + `storageState`  | Fastest  | Shared session | Default for nearly every project. Login happens once, all tests reuse the session file.             |
| Tests modify user state          | Per-worker fixture             | Fast     | Per worker     | Tests update profile, change settings, or mutate data that could conflict between parallel workers. |
| Multiple user roles              | Per-project `storageState`     | Fastest  | Per role       | App has admin/user/viewer roles. Each project gets its own state file from global setup.            |
| Testing the login page           | No `storageState`              | N/A      | Full           | Use `test.use({ storageState: { cookies: [], origins: [] } })` to override defaults.                |
| OAuth/SSO provider               | Mock the callback              | Fast     | Per test       | Never hit real OAuth providers in CI. Mock the redirect or use API session injection.               |
| MFA is required                  | TOTP generation or bypass      | Moderate | Per test       | Generate real TOTP codes from a shared secret, or use a test-mode bypass code.                      |
| Token expires mid-suite          | Session refresh fixture        | Fast     | Per check      | Fixture validates the session before use and re-authenticates if expired.                           |
| Single test needs different user | `loginAs(role)` fixture        | Moderate | Per call       | Rare: prefer per-project roles. Use when a single test must compare two roles side by side.         |
| API-first app (no login UI)      | API login via `request.post()` | Fastest  | Per test       | No browser needed for auth. Hit the API directly and capture cookies.                               |
| CI with long-running suites      | API login + session check      | Fastest  | Per worker     | Combine API login speed with session refresh for reliability over long runs.                        |

### UI Login vs API Login vs Storage State

```
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
```

## Anti-Patterns

| Don't Do This                                                             | Problem                                                                              | Do This Instead                                                                                           |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| Log in via UI before every test                                           | Adds 2-5 seconds per test. A suite of 200 tests wastes 7-17 minutes just logging in. | Use `storageState` to skip login entirely. Log in once in global setup.                                   |
| Share a single auth state file across parallel workers that mutate state  | Race conditions: worker A changes the password while worker B is mid-test.           | Use per-worker fixtures with `{ scope: 'worker' }` or per-worker test accounts.                           |
| Hardcode credentials in test files                                        | Security risk. Credentials leak into version control and CI logs.                    | Use environment variables (`process.env.TEST_USER_PASSWORD`) and `.env` files.                            |
| Ignore token expiration                                                   | Tests fail intermittently with 401 errors after running for a while.                 | Add a session validity check in your auth fixture and re-authenticate when expired.                       |
| Hit real OAuth providers in CI                                            | Flaky: provider rate limits, CAPTCHA, network issues. Slow. May violate ToS.         | Mock the OAuth callback or use API session injection with a test-only endpoint.                           |
| Use `page.waitForTimeout(2000)` after login                               | Arbitrary delay. Too slow in fast environments, too short in slow ones.              | `await page.waitForURL('/dashboard')` or `await expect(heading).toBeVisible()`.                           |
| Store `.auth/*.json` files in git                                         | Tokens and cookies in version control. Security incident waiting to happen.          | Add `.auth/` to `.gitignore`. Generate auth state in CI as part of the test run.                          |
| Create one "god" test account with all permissions                        | Cannot test role-based access control. Bugs in permission checks go undetected.      | Create separate accounts per role (admin, user, viewer) with appropriate permissions.                     |
| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated. You end up re-logging in constantly.           | Pass `storageState` when creating the context: `browser.newContext({ storageState: '.auth/user.json' })`. |
| Test MFA by disabling it everywhere                                       | You never test the MFA flow. Real users hit MFA bugs you never catch.                | Use TOTP generation from a shared secret for at least one test. Bypass MFA for the rest.                  |

## Troubleshooting

### Global setup fails with "Target page, context or browser has been closed"

**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called. Common when the login URL requires HTTPS but your test server uses HTTP.

**Fix**:

- Add `await page.waitForURL()` after the login action to ensure navigation completed.
- Check that `baseURL` in your config matches the actual server URL and protocol.
- Add error handling to global setup with a meaningful error message:

```typescript
const response = await page.waitForResponse("**/api/auth/**")
if (!response.ok()) {
  throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)
}
```

### Tests fail with 401 Unauthorized after running for a while

**Cause**: The session token saved in `storageState` has expired. Short-lived JWTs (15-minute expiry) will not survive a long test suite.

**Fix**:

- Use the session refresh fixture pattern (see Session Refresh section).
- Increase token expiry in test environment configuration.
- Switch to API-based login in a worker-scoped fixture that re-authenticates per worker.

### `storageState` file is empty or contains no cookies

**Cause**: `storageState()` was called before the login response set cookies. The login may be async (POST returns, then a redirect sets the cookie).

**Fix**:

- Wait for the post-login page to load: `await page.waitForURL('/dashboard')`.
- Wait for a specific cookie: use `await context.cookies()` in a polling check.
- Verify cookies exist before saving:

```typescript
const cookies = await context.cookies()
if (cookies.length === 0) {
  throw new Error("No cookies found after login. Check that the login flow sets cookies.")
}
await context.storageState({ path: ".auth/user.json" })
```

### Different browsers get different cookies (cross-browser auth issues)

**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior. Chromium and Firefox handle third-party cookies differently.

**Fix**:

- Generate separate auth state files per browser project.
- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS.
- In `playwright.config`, set separate `storageState` paths per project:

```typescript
projects: [
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-user.json' },
  },
  {
    name: 'firefox',
    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-user.json' },
  },
],
```

### Parallel tests interfere with each other's sessions

**Cause**: Multiple workers share the same test account and one worker's actions (logout, password change, session invalidation) affect others.

**Fix**:

- Use per-worker test accounts: `worker-${test.info().parallelIndex}@test.com`.
- Use the per-worker authentication fixture pattern (see Per-Worker Authentication section).
- Make tests idempotent: each test should work regardless of what other tests did.

### OAuth mock does not work — still redirects to real provider

**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect, or the route pattern does not match the actual redirect URL.

**Fix**:

- Register route handlers before any navigation: call `page.route()` before `page.goto()`.
- Log the actual redirect URL to verify the pattern:

```typescript
page.on("request", (req) => {
  if (req.url().includes("oauth") || req.url().includes("accounts.google")) {
    console.log("OAuth request:", req.url())
  }
})
```

- Use a broad pattern first (`**accounts.google.com**`) then narrow it down.

## Related

- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- custom fixtures for auth setup and teardown
- [core/configuration.md](configuration.md) -- `storageState`, projects, and global setup configuration
- [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) -- global setup patterns and project dependencies
- [core/network-mocking.md](network-mocking.md) -- route interception patterns used in OAuth mocking
- [core/api-testing.md](api-testing.md) -- API request context used in API-based login
- [core/flaky-tests.md](flaky-tests.md) -- diagnosing auth-related flakiness
- [core/auth-flows.md](auth-flows.md) -- complete recipes for specific auth providers (Auth0, Okta, Firebase)
