Testing Login Flows and Authentication in Playwright

A basic login test

Most authenticated apps need the same first step in every test, so it pays to get the login flow itself right before optimizing it away. A login test is three actions and an assertion:

import { test, expect } from '@playwright/test';

test('user can log in', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('a-test-password');
  await page.getByRole('button', { name: 'Log in' }).click();

  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

getByLabel finds the inputs the way a user reads the form, which the locators guide covers in depth. The second assertion matters more than the first: a URL can change before the session is fully established, but a rendered dashboard heading proves the server accepted the login and returned authenticated content.

Why logging in before every test is slow

A UI login costs two to five seconds: page load, form fill, server round trip, redirect, hydration. With 50 tests, that is up to four minutes spent re-testing a flow you already covered once. It gets worse in CI where workers run tests in parallel, because every worker hammers your auth endpoint at the same time. Rate limiters and brute-force protection start rejecting logins, and suddenly tests fail for reasons that have nothing to do with the code under test.

The login flow deserves one dedicated test. Every other test should start already authenticated.

Save authentication once with a setup project

Playwright's answer is storageState: log in once, save the session to a file, and start every test from that file. The recommended pattern uses a setup project that other projects depend on.

First, the setup file. The .setup.ts suffix is a convention that keeps it out of your normal test glob:

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

const authFile = path.join(__dirname, '../playwright/.auth/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL ?? "");
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD ?? "");
  await page.getByRole('button', { name: 'Log in' }).click();

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  await page.context().storageState({ path: authFile });
});

Then wire it into playwright.config.ts with dependencies, so the setup runs before any browser project:

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

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Every test in the chromium project now opens with the saved cookies and localStorage already in place. page.goto('/dashboard') lands directly on the dashboard, no form involved. Add playwright/.auth to .gitignore, because the file contains live session tokens.

The saved file captures cookies and localStorage but not sessionStorage. If your app keeps tokens there, the cookies and storage guide shows the workaround.

Keep credentials out of the code

The setup file above reads process.env.TEST_USER_EMAIL rather than a literal string, and that is not optional hygiene. Hardcoded credentials end up in git history, in pull request diffs, and in every clone of the repository. Even throwaway test accounts leak information about your auth setup.

Locally, load a .env file with the dotenv package in your config:

// playwright.config.ts
import dotenv from 'dotenv';

dotenv.config();

In CI, inject the same variables through your provider's secret store, such as GitHub Actions secrets. Use different accounts per environment, so a staging test can never touch production data even when someone misconfigures a base URL.

Handling "remember me" and session expiry

Storage state files go stale. Most sessions expire after hours or days, and an expired file means every test fails at once with confusing symptoms: redirects to the login page in the middle of assertions.

Two practices keep this manageable. Check the "remember me" option during setup when your app offers one, because it typically issues longer-lived cookies:

await page.getByLabel('Remember me').check();

And treat the state file as disposable. The setup project already regenerates it on every full run, so staleness only bites when you reuse a file across runs, for example on a developer machine days later. If you cache the file for speed, validate it first: request an authenticated page, and rerun the login when you get bounced.

Session expiry is also worth a test of its own. Clear the session cookie mid-test and assert that the app redirects to login instead of showing broken authenticated views.

Testing multiple roles

Apps with roles need one storage state per role. Add a setup step per account, each writing its own file:

// tests/auth.setup.ts
setup('authenticate as admin', async ({ page }) => {
  // log in with admin credentials
  await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});

setup('authenticate as user', async ({ page }) => {
  // log in with member credentials
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Then pick the role per test file with test.use:

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

test.use({ storageState: 'playwright/.auth/admin.json' });

test('admin can open billing settings', async ({ page }) => {
  await page.goto('/settings/billing');
  await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});

This also covers the negative case cleanly: run the same navigation with the member state and assert the access-denied message. Permission bugs are among the most expensive to ship, and two state files make them cheap to test.

What to do about 2FA

Two-factor authentication exists to stop exactly what your test does, so there is no clean answer, only tradeoffs.

Generating TOTP codes in the test is the most complete option. Store the TOTP secret as an environment variable and compute the current code with a library such as otpauth:

import * as OTPAuth from 'otpauth';

const totp = new OTPAuth.TOTP({ secret: process.env.TEST_USER_TOTP_SECRET ?? "" });

await page.getByLabel('Verification code').fill(totp.generate());

The tradeoff: the secret in CI is one more credential to protect, and anyone who can read your CI secrets can now pass 2FA for that account.

Disabling 2FA on a dedicated test account is simpler and common in practice. The tradeoff is honesty about coverage: the 2FA path itself goes untested, and if your organization enforces 2FA for all accounts, this option is off the table.

Backup codes work but are consumed one per login, so a CI pipeline burns through them in days. Keep them for one-off manual debugging, not automation.

Whichever you choose, do it in the setup project only. With storage state, 2FA runs once per test run instead of once per test, which also keeps you clear of TOTP rate limits.

SSO and cross-origin redirects

Single sign-on sends the browser to a different origin, through the identity provider's login form, and back. Playwright follows cross-origin navigation without special configuration, so the test reads like the user experience:

await page.goto('/login');
await page.getByRole('button', { name: 'Continue with SSO' }).click();

// now on the identity provider's domain
await page.getByLabel('Email').fill(process.env.SSO_EMAIL ?? "");
await page.getByLabel('Password').fill(process.env.SSO_PASSWORD ?? "");
await page.getByRole('button', { name: 'Sign in' }).click();

// wait out the redirect chain back to the app
await page.waitForURL('**/dashboard');

waitForURL is the key call. The redirect chain can pass through several intermediate URLs, and asserting on an element too early races against navigation. Waiting for the final URL first, then asserting on content, is the stable order. Redirect chains through an IdP can be slow, so give this step a generous timeout if it flakes; the waits and timeouts guide explains how the navigation timeout applies here.

Test against a test tenant of your identity provider, such as an Okta developer org or a staging realm, rather than consumer Google accounts. Consumer providers aggressively fingerprint automated browsers, which brings us to the last topic.

Bot detection and captchas

Captchas and bot detection are designed to defeat automation, and trying to outsmart them puts your test suite in an arms race it will lose. A workaround that passes today breaks silently after the next detection update, and captcha-solving services are slow, paid, and against most providers' terms.

The sustainable approaches all avoid the challenge instead of solving it. Run tests against a staging environment with the captcha disabled, or configured with test keys, which reCAPTCHA provides exactly for this purpose. Keep dedicated test accounts on paths that do not trigger challenges, since captchas usually appear on signup and suspicious-login flows rather than routine logins. Where the provider supports it, allowlist your CI egress IPs.

If a captcha appears mid-test anyway, treat it as an environment configuration bug, not a scripting problem.

Run it in production

The login flow is the one path that locks every user out when it breaks, which makes it the first candidate for continuous checking. Hyperping browser checks run this same Playwright script against production on a schedule, from multiple regions, and alert you the moment the login stops working.

Get started

Start monitoring in the next 5 minutes.

Stop letting customers discover your outages first. Set up monitoring, status pages, on-call, and alerts before your next coffee break.

14 days free trial. No card required.