Tutorial August 17, 2026 13 min read

Playwright Login Test Tutorial: Automate Authentication Testing (2026)

Login is the first thing users do and the first test most teams automate. This tutorial walks you through writing Playwright login tests from scratch — covering form-based auth, accessible locators, validation testing, reusable auth state with storageState, OAuth strategies, MFA handling, and the Page Object Model. Every example uses TypeScript.

Why Login Tests Matter

Authentication is the critical path of every web application. If users can't log in, nothing else matters — your features, your UI, your entire product is unreachable. That's why login tests are typically the first automated tests any team writes.

Login flows are deceptively complex. A seemingly simple email-and-password form involves:

  • Form validation — empty fields, invalid email formats, incorrect passwords
  • Session management — cookies, tokens, localStorage, session expiry
  • Security constraints — rate limiting, account lockouts, CSRF protection
  • Third-party integrations — OAuth providers like Google, GitHub, Microsoft
  • Multi-factor authentication — TOTP codes, SMS verification, backup codes
  • Cross-browser behavior — autofill, password managers, credential APIs

A broken login page means zero conversions, zero engagement, and a flood of support tickets. Automating login tests with Playwright gives you a safety net that catches regressions before they reach production — on every commit, across every browser.

Real-world impact: Teams with automated login tests catch auth regressions an average of 12 hours before they would be discovered by manual QA. For a SaaS product with 10,000 daily active users, that's the difference between a silent fix and a P0 incident.


Basic Login Test

Let's start with the most common scenario: a form with an email field, a password field, and a submit button. After successful login, the user is redirected to a dashboard.

login.spec.ts
import { test, expect } from '@playwright/test';

test('successful login redirects to dashboard', async ({ page }) => {
  // Navigate to the login page
  await page.goto('https://myapp.com/login');

  // Fill in credentials
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('SecureP@ss123');

  // Click the sign-in button
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Assert: user lands on the dashboard
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

This test is intentionally simple, but it covers the essential happy path. Playwright's auto-waiting handles all the timing for you — it waits for the email field to be ready before filling, waits for the button to be clickable before clicking, and waits for the URL to change before asserting. You don't need any sleep() or waitForTimeout() calls.

A few things to notice:

  • getByLabel('Email') finds the input associated with a <label> element — this is the most reliable and accessible locator strategy
  • getByRole('button', { name: 'Sign In' }) finds the button by its accessible name, not a CSS selector that could break
  • toHaveURL('/dashboard') accepts partial URLs and automatically retries until the assertion passes or times out

Using getByRole and getByLabel for Login Forms

Playwright provides multiple locator strategies, but for login forms, accessible locators are the best choice. They mirror how real users (and screen readers) interact with your UI, and they're more resilient to DOM changes than CSS selectors or XPaths.

getByLabel — The Best Choice for Form Inputs

getByLabel() finds an input by its associated <label> text. This is the gold standard for form fields because it relies on the semantic relationship between labels and inputs:

HTML (your app's login form)
<!-- Option 1: Label with for/id -->
<label for="email">Email address</label>
<input id="email" type="email" />

<!-- Option 2: Label wrapping input -->
<label>
  Password
  <input type="password" />
</label>

<!-- Option 3: aria-label -->
<input type="email" aria-label="Email address" />
Playwright test
// All three HTML patterns above are matched by:
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('SecureP@ss123');

getByRole — For Buttons and Links

getByRole() finds elements by their ARIA role and accessible name. It's perfect for submit buttons, navigation links, and any interactive element:

Playwright test
// Submit button
await page.getByRole('button', { name: 'Sign In' }).click();

// "Forgot password?" link
await page.getByRole('link', { name: 'Forgot password?' }).click();

// "Remember me" checkbox
await page.getByRole('checkbox', { name: 'Remember me' }).check();

// Heading on the login page
await expect(page.getByRole('heading', { name: 'Welcome back' })).toBeVisible();

getByPlaceholder — Fallback When Labels Are Missing

Not every form has proper labels (it should, but reality is messy). If your login form only has placeholder text, use getByPlaceholder():

Playwright test
// When the form has no labels, only placeholders
await page.getByPlaceholder('Enter your email').fill('user@example.com');
await page.getByPlaceholder('Enter your password').fill('SecureP@ss123');

Avoid CSS selectors for login forms. Locators like page.locator('#email-input') or page.locator('.login-form input:nth-child(2)') break when developers rename IDs, restructure the DOM, or switch CSS frameworks. Accessible locators like getByLabel and getByRole survive these changes because they rely on the user-facing semantics, not implementation details.


Testing Login Validation

The happy path is just one test. Most login bugs live in the error handling. You need to verify that your app correctly rejects invalid inputs and shows the right error messages.

Wrong Password

login-validation.spec.ts
test('shows error for wrong password', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('WrongPassword!');
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Assert: error message is visible
  await expect(page.getByText('Invalid email or password')).toBeVisible();

  // Assert: user stays on login page
  await expect(page).toHaveURL('/login');
});

Empty Fields

login-validation.spec.ts
test('shows validation error for empty email', async ({ page }) => {
  await page.goto('/login');
  // Leave email empty, fill only password
  await page.getByLabel('Password').fill('SecureP@ss123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Assert: browser validation or custom error
  await expect(page.getByText('Email is required')).toBeVisible();
});

test('shows validation error for empty password', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  // Leave password empty
  await page.getByRole('button', { name: 'Sign In' }).click();

  await expect(page.getByText('Password is required')).toBeVisible();
});

Account Lockout

login-validation.spec.ts
test('locks account after 5 failed attempts', async ({ page }) => {
  await page.goto('/login');

  // Attempt login 5 times with wrong password
  for (let i = 0; i < 5; i++) {
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('WrongPassword!');
    await page.getByRole('button', { name: 'Sign In' }).click();
  }

  // Assert: account lockout message appears
  await expect(
    page.getByText('Account locked. Please try again in 15 minutes.')
  ).toBeVisible();

  // Assert: sign-in button is disabled
  await expect(page.getByRole('button', { name: 'Sign In' })).toBeDisabled();
});

Invalid Email Format

login-validation.spec.ts
test('shows error for invalid email format', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('not-an-email');
  await page.getByLabel('Password').fill('SecureP@ss123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  await expect(page.getByText('Please enter a valid email address')).toBeVisible();
});

Security note: Good login forms show a generic error like "Invalid email or password" rather than "Password incorrect" or "User not found." The generic message prevents attackers from enumerating valid email addresses. Your tests should verify your app follows this pattern.


Reusing Authentication State with storageState

Logging in before every test is slow and redundant. If you have 50 tests that require authentication, you don't want to fill out the login form 50 times. Playwright's storageState feature lets you log in once, save the session (cookies + localStorage), and reuse it across all tests.

Step 1: Create an Auth Setup File

Create a setup script that performs the login and saves the session to a JSON file:

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

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  // Perform the login
  await page.goto('https://myapp.com/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('SecureP@ss123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Wait for the redirect to confirm login succeeded
  await page.waitForURL('/dashboard');

  // Save the authenticated state (cookies + localStorage)
  await page.context().storageState({ path: authFile });
});

Step 2: Use the Saved State in Tests

Now any test can start already logged in — without touching the login form:

tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';

// Use the saved auth state — no login needed
test.use({ storageState: 'playwright/.auth/user.json' });

test('dashboard shows user profile', async ({ page }) => {
  await page.goto('/dashboard');

  // Already logged in — verify the dashboard loads
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByText('user@example.com')).toBeVisible();
});

The user.json file contains all the cookies and localStorage entries from the authenticated session. When Playwright loads this state, the browser behaves exactly as if the user had just logged in.

Add playwright/.auth/ to your .gitignore. The auth state file may contain session tokens that shouldn't be committed to version control.


Setting Up Global Auth in playwright.config.ts

Instead of adding test.use({ storageState: ... }) to every test file, you can configure it globally using project dependencies. This is the recommended approach for production test suites.

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

export default defineConfig({
  projects: [
    // Setup project — runs first, performs login
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

    // Chromium tests — depend on setup, use saved auth
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },

    // Firefox tests — same dependency
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },

    // WebKit tests — same dependency
    {
      name: 'webkit',
      use: {
        ...devices['Desktop Safari'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Here's how this works:

  1. The setup project runs first. It matches auth.setup.ts, performs the login, and saves the session to user.json.
  2. Browser projects (chromium, firefox, webkit) declare dependencies: ['setup'], so they wait for setup to complete.
  3. Every test in those projects starts with the saved storageState — already authenticated, zero login overhead.

This approach scales beautifully. With 200 tests across 3 browsers, login happens once instead of 600 times. That can save minutes on every CI run.

Multiple User Roles

If your app has admin and regular user roles, create separate setup files and storage states:

playwright.config.ts (multiple roles)
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'admin-tests',
    use: {
      storageState: 'playwright/.auth/admin.json',
    },
    dependencies: ['setup'],
    testMatch: '**/admin/**',
  },
  {
    name: 'user-tests',
    use: {
      storageState: 'playwright/.auth/user.json',
    },
    dependencies: ['setup'],
    testMatch: '**/user/**',
  },
],

Testing OAuth / Social Login

Many modern apps offer "Sign in with Google," "Sign in with GitHub," or similar OAuth flows. Testing these is tricky because the login page belongs to a third party — you don't control it, and these providers actively block automation.

Why You Shouldn't Automate Third-Party Login Pages

  • CAPTCHAs and bot detection — Google, GitHub, and Microsoft detect Playwright and show CAPTCHAs or block the request entirely
  • UI changes without notice — the provider can change their login form at any time, breaking your tests
  • Rate limiting — automated logins from CI IPs get flagged and throttled
  • Terms of Service — automating third-party login pages may violate the provider's ToS

Strategy 1: API-Based Authentication

The most reliable approach is to bypass the OAuth UI entirely and authenticate via API. Most OAuth implementations ultimately set a session cookie or JWT token. You can obtain this token directly and inject it:

oauth-setup.ts
import { test as setup } from '@playwright/test';

setup('authenticate via API', async ({ page, request }) => {
  // Call your app's test-only auth endpoint
  const response = await request.post('https://myapp.com/api/test-auth', {
    data: {
      email: 'user@example.com',
      provider: 'google',
    },
  });

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

  // Navigate to the app and inject the token
  await page.goto('https://myapp.com');
  await page.evaluate((t) => {
    localStorage.setItem('auth_token', t);
  }, token);

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

Strategy 2: Inject Cookies Directly

If your app uses cookie-based sessions after OAuth, you can set the session cookie directly:

cookie-injection.ts
setup('inject session cookie', async ({ context }) => {
  // Add the session cookie directly to the browser context
  await context.addCookies([
    {
      name: 'session_id',
      value: process.env.TEST_SESSION_TOKEN!,
      domain: 'myapp.com',
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'Lax',
    },
  ]);

  // Save state for reuse
  await context.storageState({ path: 'playwright/.auth/user.json' });
});

Strategy 3: Test-Only Login Bypass

Ask your development team to create a test-only authentication endpoint that is only available in staging/test environments. This is the cleanest solution for CI pipelines:

Example: test-only endpoint
// Server-side: only enabled when NODE_ENV=test
app.post('/api/test-login', (req, res) => {
  if (process.env.NODE_ENV !== 'test') {
    return res.status(404).send('Not found');
  }
  const { email } = req.body;
  const session = createSession(email);
  res.cookie('session_id', session.token, { httpOnly: true });
  res.json({ success: true });
});

Never expose test-only auth endpoints in production. Use environment variables, feature flags, or network restrictions to ensure these endpoints are completely unreachable in production deployments.


Handling MFA / Two-Factor Authentication

Multi-factor authentication adds a second verification step after the password. The most common type is TOTP (Time-based One-Time Password) — the 6-digit codes from apps like Google Authenticator or Authy.

TOTP with otplib

If your test environment uses TOTP-based MFA, you can generate valid codes programmatically using the otplib npm package:

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

const MFA_SECRET = process.env.MFA_SECRET!;
// The shared secret from when MFA was first set up
// e.g., 'JBSWY3DPEHPK3PXP'

setup('login with MFA', async ({ page }) => {
  // Step 1: Normal login
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('SecureP@ss123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Step 2: MFA verification page appears
  await page.waitForURL('/mfa-verify');

  // Step 3: Generate a valid TOTP code
  const otpCode = authenticator.generate(MFA_SECRET);

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

  // Step 5: Confirm login and save state
  await page.waitForURL('/dashboard');
  await page.context().storageState({
    path: 'playwright/.auth/user.json',
  });
});

The MFA_SECRET is the base32-encoded shared secret that was generated when the user first enabled MFA. Store it as a CI environment variable (MFA_SECRET in GitHub Actions secrets, for example). The authenticator.generate() function produces a valid 6-digit code based on the current time — exactly like Google Authenticator would.

Backup Codes

For test environments, using backup codes can be simpler than TOTP. When setting up the test account, save the backup codes and use them in your setup script:

auth-backup-code.setup.ts
setup('login with backup code', async ({ page }) => {
  // After password login, on the MFA page...
  await page.getByRole('link', { name: 'Use a backup code' }).click();
  await page.getByLabel('Backup code').fill(process.env.MFA_BACKUP_CODE!);
  await page.getByRole('button', { name: 'Verify' }).click();

  await page.waitForURL('/dashboard');
  await page.context().storageState({
    path: 'playwright/.auth/user.json',
  });
});

Best practice: Always save the authenticated state with storageState after MFA. This way, the TOTP/backup code step only runs once during the setup project, and all subsequent tests start already authenticated.


Session Management Tests

Beyond login itself, you need to test how your application handles sessions over time. These tests catch bugs that only surface after users have been logged in for a while.

Token Expiry

session.spec.ts
test('redirects to login when session expires', async ({ page, context }) => {
  // Start logged in
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Clear cookies to simulate session expiry
  await context.clearCookies();

  // Try navigating to a protected page
  await page.goto('/settings');

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

test('shows session expired message after inactivity', async ({ page }) => {
  await page.goto('/dashboard');

  // Simulate expired token by manipulating localStorage
  await page.evaluate(() => {
    const token = JSON.parse(localStorage.getItem('auth')!);
    token.expiresAt = Date.now() - 1000; // Set expiry to the past
    localStorage.setItem('auth', JSON.stringify(token));
  });

  // Trigger a page action that checks the token
  await page.getByRole('link', { name: 'Settings' }).click();

  await expect(page.getByText('Your session has expired')).toBeVisible();
});

Remember Me

session.spec.ts
test('remember me keeps user logged in after browser restart', async ({
  page,
  context,
}) => {
  // Log in with "Remember me" checked
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('SecureP@ss123');
  await page.getByRole('checkbox', { name: 'Remember me' }).check();
  await page.getByRole('button', { name: 'Sign In' }).click();
  await page.waitForURL('/dashboard');

  // Save state, then open a new context (simulates browser restart)
  const state = await context.storageState();
  const newContext = await page.context().browser()!.newContext({
    storageState: state,
  });
  const newPage = await newContext.newPage();

  // Navigate directly to a protected page
  await newPage.goto('/dashboard');
  await expect(newPage).toHaveURL('/dashboard'); // Not redirected to login

  await newContext.close();
});

Logout

session.spec.ts
test('logout clears session and redirects to login', async ({ page }) => {
  await page.goto('/dashboard');

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

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

  // Verify protected routes are no longer accessible
  await page.goto('/dashboard');
  await expect(page).toHaveURL('/login'); // Redirected back

  // Verify "back" button doesn't expose authenticated content
  await page.goBack();
  await expect(page).toHaveURL('/login');
});

Page Object Model for Login

As your login tests grow, you'll find yourself repeating the same locators and actions in every test file. The Page Object Model (POM) solves this by encapsulating all login page interactions in a single reusable class.

The LoginPage Class

pages/login.page.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 rememberMeCheckbox: Locator;
  readonly forgotPasswordLink: Locator;
  readonly errorMessage: 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.rememberMeCheckbox = page.getByRole('checkbox', {
      name: 'Remember me',
    });
    this.forgotPasswordLink = page.getByRole('link', {
      name: 'Forgot password?',
    });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

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

  async loginWithRememberMe(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.rememberMeCheckbox.check();
    await this.signInButton.click();
  }

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

  async expectOnLoginPage() {
    await expect(this.page).toHaveURL('/login');
    await expect(this.signInButton).toBeVisible();
  }
}

Using the LoginPage in Tests

login-pom.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';

test.describe('Login page', () => {
  let loginPage: LoginPage;

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

  test('successful login', async ({ page }) => {
    await loginPage.login('user@example.com', 'SecureP@ss123');
    await expect(page).toHaveURL('/dashboard');
  });

  test('wrong password shows error', async () => {
    await loginPage.login('user@example.com', 'WrongPassword!');
    await loginPage.expectError('Invalid email or password');
  });

  test('empty email shows validation error', async () => {
    await loginPage.login('', 'SecureP@ss123');
    await loginPage.expectError('Email is required');
  });

  test('remember me keeps session', async ({ page }) => {
    await loginPage.loginWithRememberMe('user@example.com', 'SecureP@ss123');
    await expect(page).toHaveURL('/dashboard');
  });
});

The benefits of this pattern are significant:

  • Single source of truth — when the login form changes (new field name, different button text), you update one file
  • Readable testsloginPage.login(email, password) is clearer than 3 lines of fill/click
  • Reusable across test suites — import LoginPage in any spec file that needs login
  • Type safety — TypeScript gives you autocomplete and compile-time checks on the page object methods

Pro tip: Keep page objects focused. The LoginPage should only contain locators and actions for the login page. Don't add dashboard or settings logic here. Create separate DashboardPage and SettingsPage classes for those. For a deep dive, see our Playwright Page Object Model tutorial.



Frequently Asked Questions

How do I write a basic login test in Playwright?

Use page.goto() to navigate to the login page, page.getByLabel() to locate the email and password fields, .fill() to enter credentials, page.getByRole('button', { name: 'Sign In' }).click() to submit, and expect(page).toHaveURL('/dashboard') to verify the redirect. A complete test takes about 10 lines of TypeScript. Playwright's auto-waiting handles all timing automatically.

How do I reuse login state across multiple Playwright tests?

Use Playwright's storageState feature. Create a setup project that logs in once and saves cookies and localStorage to a JSON file with page.context().storageState({ path: 'auth.json' }). Then configure your test projects to load that file via storageState in playwright.config.ts. This avoids repeating login in every test and can save minutes on each CI run.

Can Playwright handle OAuth and social login (Google, GitHub)?

Playwright can technically automate OAuth flows, but it's not recommended for third-party login pages. Google, GitHub, and Microsoft actively block automation with CAPTCHAs and bot detection. Instead, use API-based authentication to obtain tokens directly, inject them via storageState or context.addCookies(), or set up a test-only login bypass endpoint in your staging environment.

How do I test two-factor authentication (MFA) with Playwright?

For TOTP-based MFA, install the otplib npm package and call authenticator.generate(secret) to produce valid 6-digit codes from the shared secret. Fill the code into the MFA input and submit. For SMS-based MFA, use a fixed test phone number or bypass MFA in test environments. Always save the authenticated session with storageState so MFA only runs once during setup.

What is the Page Object Model for login tests in Playwright?

The Page Object Model (POM) encapsulates login page interactions in a reusable LoginPage class. The class stores locators (email, password, submit button) as properties and exposes methods like login(email, password) and expectError(message). Tests call these methods instead of repeating locator logic. When the login form changes, you update the page object file once instead of every test.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn