ProductHow it worksPricingBlogDocsLoginFind Your First Bug
Diagram showing a Playwright auth setup flow: a login-once setup test saves storageState to a JSON file, multiple parallel worker tests load the state and skip login entirely, cutting per-test auth time from seconds to milliseconds
TestingPlaywrightAuthentication+1

Playwright Authentication: Cut Login Time by 80% with storageState

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Playwright authentication is the practice of signing in once in a setup test, saving the browser's authenticated state with storageState, and reusing it so every test starts already logged in, cutting auth time by 60-80% (from 2-5 seconds per test down to under 500ms). The modern pattern uses a setup project with dependencies: ['setup'] in your Playwright config.

A startup we talked to had a coding agent sweep their codebase. The agent cleaned up some "dead code." Among what it deleted: the auth wrapper that every protected-route test depended on. The suite went green. No test failed. The auth wrapper wasn't tested. Two days later, every user in production was locked out. Not because login broke, it was the auth check itself that silently stopped running.

Your auth setup is often the most brittle code in your test suite. It sits outside your test files, it relies on the live login UI, and it has no tests of its own. When it breaks, it takes everything with it quietly.

This guide covers how Playwright handles authentication properly: the storageState core pattern, setup projects (not globalSetup, which is legacy), multiple roles, API-based login, parallel isolation, the honest story on third-party OAuth, and a per-provider decision table that doesn't exist anywhere else on the internet.

What authentication means in a Playwright test

Every Playwright test runs in an isolated browser context. That context starts fresh: no cookies, no local storage, no session tokens. If your app requires login to reach any meaningful state, every test that skips the login UI is a test that gets to run in roughly 500ms instead of 3-5 seconds.

At scale, this matters. A suite with 200 tests, each spending 3 seconds on login, burns 10 minutes on authentication alone before a single assertion fires. That's not a testing problem, it's a math problem.

Playwright's answer is context reuse. You authenticate once, serialize the resulting browser state (cookies, local storage, indexed DB) to a JSON file, and hand that file to every subsequent test context. Each test starts as if it just completed the login flow, because from the browser's perspective, it did.

The isolation still holds: each test gets its own context instance with its own copy of the state. One test cannot corrupt another's session.

Playwright storageState: log in once, reuse auth across tests

The save side is simple. You log in through the UI, then call page.context().storageState({ path: '.auth/user.json' }). Playwright writes all cookies and storage to that file.

import { test as setup } from '@playwright/test';
import path from 'path';

export const STORAGE_STATE = path.join(__dirname, '../.auth/user.json');

/**
 * Auth setup test: runs once before the main test suite.
 * Navigates to the login page, submits credentials, and saves
 * the resulting browser state (cookies + localStorage) to disk.
 *
 * Run: npx playwright test --project=setup
 */
setup('authenticate', async ({ page }) => {
  const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';

  await page.goto(`${baseURL}/login`);

  // Fill the login form — adjust selectors to match your app.
  await page.getByLabel('Email').fill(process.env.TEST_EMAIL ?? 'test@example.com');
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD ?? 'password');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait until the app has fully navigated to the post-login page.
  await page.waitForURL(`${baseURL}/dashboard`);

  // Serialize cookies + storage so subsequent tests can reuse this session.
  await page.context().storageState({ path: STORAGE_STATE });
});

The load side is even simpler. In your playwright.config.ts, you point a project at the state file with storageState: '.auth/user.json', and you use a setup project with dependencies: ['setup'] to ensure the state is populated before any test that needs it runs.

// NOTE: globalSetup is the legacy approach (pre-v1.31). Use setup projects instead.
import { defineConfig, devices } from '@playwright/test';

/**
 * Full Playwright config demonstrating the modern setup-project pattern.
 *
 * - The "setup" project runs auth.setup.ts files first.
 * - The "chromium" project declares setup as a dependency and loads the
 *   saved storageState so every test starts already authenticated.
 *
 * Docs: https://playwright.dev/docs/auth
 */
export default defineConfig({
  testDir: './src',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
  },

  projects: [
    {
      name: 'setup',
      testMatch: '**/*.setup.ts',
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Load the saved auth state before every test in this project.
        storageState: '.auth/user.json',
      },
      // The setup project must finish before any chromium test starts.
      dependencies: ['setup'],
    },
  ],
});

A word on globalSetup: it's the old way. Before Playwright v1.31, you'd pass a path to a setup module via the globalSetup config field. It worked but it ran outside the test runner, couldn't use fixtures, had worse error reporting, and wasn't parallelism-aware. Use setup projects instead. The config example above marks the legacy pattern explicitly so you know what to ignore when you find it in older Stack Overflow answers.

Diagram showing a Playwright storageState setup test logging in once, saving cookies and localStorage to storageState.json, and parallel workers loading the authenticated state instead of logging in per test
The setup test pays the login cost once; each worker loads the saved storageState and starts already authenticated.

Authenticating multiple roles

Most apps have more than one role, and their tests need distinct auth states. An admin test should start with admin cookies; a viewer test should start with viewer cookies. Sharing one state file between both is a bug waiting to happen.

The pattern: one setup test per role, each writing to its own file (.auth/admin.json, .auth/user.json). Each project in your config points at the appropriate state file and declares the matching setup project as its dependency.

import { test as setup, Browser, BrowserContext } from '@playwright/test';
import path from 'path';

const ADMIN_STATE = path.join(__dirname, '../.auth/admin.json');
const USER_STATE  = path.join(__dirname, '../.auth/user.json');

const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000';

/**
 * Multi-role auth setup.
 *
 * Two setup tests, each writing to a separate state file.
 * In playwright.config.ts, define matching projects:
 *
 *   { name: 'setup-admin', testMatch: 'multi-role.setup.ts#admin' }
 *   { name: 'setup-user',  testMatch: 'multi-role.setup.ts#user'  }
 *
 * Or combine them into a single setup project by putting both .setup.ts
 * files in the same directory and using testMatch: '**\/*.setup.ts'.
 *
 * Run (separate projects): npx playwright test --project=setup-admin --project=setup-user
 */

setup('authenticate as admin', async ({ page }) => {
  await page.goto(`${BASE_URL}/login`);

  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL ?? 'admin@example.com');
  await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD ?? 'adminpassword');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL(`${BASE_URL}/admin/dashboard`);
  await page.context().storageState({ path: ADMIN_STATE });
});

setup('authenticate as user', async ({ page }) => {
  await page.goto(`${BASE_URL}/login`);

  await page.getByLabel('Email').fill(process.env.USER_EMAIL ?? 'user@example.com');
  await page.getByLabel('Password').fill(process.env.USER_PASSWORD ?? 'userpassword');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL(`${BASE_URL}/dashboard`);
  await page.context().storageState({ path: USER_STATE });
});

/**
 * Example: a single test that opens both an admin context and a user context
 * simultaneously, simulating two roles interacting in real time.
 *
 * Usage (separate from this setup file — place in a regular test file):
 *
 *   test('admin grants access; user sees the change', async ({ browser }) => {
 *     const adminContext = await browser.newContext({ storageState: '.auth/admin.json' });
 *     const userContext  = await browser.newContext({ storageState: '.auth/user.json' });
 *
 *     const adminPage = await adminContext.newPage();
 *     const userPage  = await userContext.newPage();
 *
 *     await adminPage.goto('/admin/permissions');
 *     await adminPage.getByRole('button', { name: 'Grant access' }).click();
 *
 *     await userPage.goto('/dashboard');
 *     await userPage.getByText('Access granted').waitFor();
 *
 *     await adminContext.close();
 *     await userContext.close();
 *   });
 */

The multi-context detail matters for tests that need to simulate two users interacting, for example, an admin granting access while a viewer is logged in. You open two browser contexts in a single test, each loaded with a different state file. Playwright handles this natively.

Skip the UI: API-based login

Hitting the login UI in your setup test adds the same 2-5 second overhead you're trying to avoid everywhere else. For apps that expose a login endpoint (REST or GraphQL), you can skip the UI entirely.

The request fixture gives you an HTTP client that shares Playwright's cookie jar. You POST your credentials directly, get a session cookie back, and call request.storageState() to serialize the result. No browser, no page load, no selector fragility.

import { test as setup, request } from '@playwright/test';
import path from 'path';

export const STORAGE_STATE = path.join(__dirname, '../.auth/user.json');

/**
 * API-based auth setup: POSTs credentials directly to the login endpoint
 * instead of going through the UI. This avoids page loads, fragile selectors,
 * and redesign-induced breakage.
 *
 * Faster and more stable than UI login for auth setup purposes.
 * Note: this bypasses client-side login validation — keep a separate UI
 * login test if you want to verify the login flow itself.
 *
 * Requires a running app at BASE_URL with a REST login endpoint that sets
 * session cookies on success.
 *
 * Run: npx playwright test --project=setup
 */
setup('authenticate via API', async () => {
  const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';

  // Create a standalone request context — no browser needed.
  const requestContext = await request.newContext({
    baseURL,
  });

  const response = await requestContext.post('/api/auth/login', {
    data: {
      email:    process.env.TEST_EMAIL    ?? 'test@example.com',
      password: process.env.TEST_PASSWORD ?? 'password',
    },
  });

  if (!response.ok()) {
    throw new Error(
      `API login failed: ${response.status()} ${await response.text()}`
    );
  }

  // Serialize cookies from the request context (includes the session cookie
  // set by the login endpoint) to a JSON file that test projects can reuse.
  await requestContext.storageState({ path: STORAGE_STATE });
  await requestContext.dispose();
});

API login is faster and more stable than UI login. It doesn't break when the login page gets redesigned. The tradeoff: it bypasses any client-side validation or redirect logic that runs during login, so it's not a replacement for testing the login flow itself. Use it for auth setup, not for login testing.

Running in parallel without breaking auth

Playwright tests run in parallel by default. If multiple workers write to the same .auth/user.json at the same time, you get a race condition. The fix is testInfo.parallelIndex: each worker gets a unique integer, and you use it to give each worker its own state file.

import { test as setup, test, expect, Page } from '@playwright/test';
import path from 'path';
import fs from 'fs';

const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000';

/**
 * Per-worker auth setup.
 *
 * Uses testInfo.parallelIndex to write each Playwright worker's auth state
 * to a unique file (.auth/user-0.json, .auth/user-1.json, ...) so that
 * concurrent workers never collide on the same file.
 *
 * NOTE: this is an advanced pattern for suites where server-side session
 * data conflicts across concurrent requests (e.g., per-session rate limits
 * or mutating fixtures). If your app handles concurrent sessions correctly,
 * the default single-setup-project approach is the right choice.
 *
 * Run: npx playwright test --workers=4
 */
setup('authenticate per worker', async ({ page }, testInfo) => {
  const stateFile = path.join(
    __dirname,
    `../.auth/user-${testInfo.parallelIndex}.json`
  );

  // Ensure the .auth directory exists before writing.
  fs.mkdirSync(path.dirname(stateFile), { recursive: true });

  await page.goto(`${BASE_URL}/login`);

  await page.getByLabel('Email').fill(process.env.TEST_EMAIL ?? 'test@example.com');
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD ?? 'password');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL(`${BASE_URL}/dashboard`);
  await page.context().storageState({ path: stateFile });
});

/**
 * Custom fixture that loads the per-worker state file so individual tests
 * receive an already-authenticated page without any extra configuration.
 *
 * Usage in tests:
 *
 *   import { test } from './parallel.setup';
 *
 *   test('dashboard loads', async ({ authedPage }) => {
 *     await authedPage.goto('/dashboard');
 *     await expect(authedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
 *   });
 */
export const authedTest = test.extend<{ authedPage: Page }>({
  authedPage: async ({ browser }, use, testInfo) => {
    const stateFile = path.join(
      __dirname,
      `../.auth/user-${testInfo.parallelIndex}.json`
    );

    const context = await browser.newContext({ storageState: stateFile });
    const authedPage = await context.newPage();

    await use(authedPage);

    await context.close();
  },
});

This is worth naming explicitly: the setup-project pattern with dependencies: ['setup'] already handles this correctly if the setup project is configured to run once before all workers. The per-worker pattern is for cases where your auth state is tied to server-side session data that conflicts across concurrent requests. Vitalets (the Playwright contributor) has argued that most teams reach for per-worker isolation prematurely. Start with a single setup project. Add per-worker isolation only when you observe actual conflicts. For the broader set of conventions this builds on, see our Playwright best practices for 2026.

The hard part: third-party login (Google, Apple, social)

This is the section that other guides skip or defer to a footnote.

Google, Apple, and most social OAuth providers actively block automated login. Google's login page detects headless browsers, Puppeteer-style automation, and known automation fingerprints, then shows a "This browser or app may not be secure" wall. Stealth plugins (playwright-extra with the stealth plugin) worked for a while. Most of them broke in 2022-2023 when providers updated their detection. One popular deep-dive on automating real Google login, widely cited on Stack Overflow, hasn't had a working dependency update since 2023.

The reasons automation fails here:

  • Google and Apple use behavioral fingerprinting, not just user-agent strings
  • CAPTCHA triggers appear after repeated automated attempts, even from the same IP
  • Two-factor prompts appear unpredictably, breaking the flow mid-run
  • Provider-side bot detection is updated continuously

The honest answer is: don't automate the live third-party OAuth login. Instead, pick one of three approaches that actually work:

Option A: Reuse a captured long-lived session via storageState. Log in manually once with your test account, save the state, and commit the saved session to CI secrets (not to your repo). Refresh it when it expires. This is the most common approach and it works well for Google Workspace and Apple ID accounts created for testing.

Option B: Mock the OAuth redirect. Rather than hitting Google's servers, intercept the OAuth redirect in your app and inject a fixture identity. This is framework-specific (covered in the per-provider table below) but it's the fastest and most reliable approach for CI.

Option C: Use a provider test account or test-mode bypass. Most serious auth providers (Clerk, Auth0, Supabase) offer test modes or test credentials that skip third-party OAuth entirely. Use those. They're designed for exactly this.

A brief note for teams using agent-driven testing: an agent navigating the real login UI sidesteps the abandoned stealth-plugin approach by driving a real, non-headless browser context, which passes basic fingerprinting. But it still hits rate limits and 2FA prompts. The session-capture approach remains the right default.

Testing across auth providers: Clerk, NextAuth, Auth0, Supabase

This table exists because no other guide on the SERP covers it. Each provider has a fastest-reliable approach that differs from the others, and each has a specific failure mode that will cost you time if you hit it blind.

For deep-dives on specific providers: the Clerk authentication end-to-end guide and the NextAuth testing guide cover those patterns in full. The Auth0 and Supabase deep-dives go further than the table below.

Diagram comparing Playwright authentication strategies across Clerk, NextAuth, Auth0, and Supabase with each provider's recommended programmatic setup approach
Each provider has a different reliable shortcut; the decision is which setup path creates authenticated state without coupling every test to the login UI.
Provider / FlowFastest reliable approachWhat to avoidWhere it rots
Username/password (any)UI login in setup test, save storageStateLogging in per test (2-5s overhead)Login page redesigns break selectors
Google/Apple/social OAuthCaptured session or mock OAuth callbackAutomating real Google/Apple loginSession expires; stealth deps rot
ClerkTesting Tokens API (bypasses bot detection, mints a session)Real Clerk login UI (rate-limited in CI)Token TTL; rotate with Clerk dashboard
NextAuth / Auth.jsMint the NextAuth session JWT, inject via storageState cookieUI login flow (slow, brittle selectors)JWT secret rotation breaks minted tokens
Auth0Resource Owner Password grant (REST API login, no UI)Real Auth0 Universal Login page in CIGrant must be enabled in tenant settings
Supabasesupabase-js signInWithPassword, persist session to storageStateUI login (adds Supabase widget dependency)Service role key in CI secrets must rotate

The Clerk Testing Tokens approach is notable because it's the only one in this table where the provider has built an explicit testing contract: an API endpoint that generates a short-lived token your test can use to sign in programmatically, bypassing all bot detection. If you're on Clerk, use it. The full setup is covered in the Clerk deep-dive.

The NextAuth / Auth.js approach requires access to your app's NEXTAUTH_SECRET environment variable. You generate a session JWT that matches what NextAuth would set, inject it as the next-auth.session-token cookie via context.addCookies(), and load the page. From Playwright's perspective, the user is already logged in.

Where Autonoma fits with programmatic auth setup

Programmatic auth setup is still the right foundation for most Playwright suites. It keeps the bulk of your tests fast, deterministic, and focused on authenticated product behavior. The gap is the real browser login path: the route that sends a user to the login UI, the callback that establishes the session, and the protected page that should prove the session is valid after redirect.

Autonoma complements the setup patterns above by driving that real browser path on pull requests. The Planner agent reads your routes and auth middleware to identify the flows that matter. The Executor agent drives the browser through the login path and checks the authenticated state after redirect. The Diffs Agent watches auth routes, callbacks, and login UI changes on PRs so the test plan updates when that surface changes instead of waiting for a saved-state fixture to rot. The result is not a replacement for storageState; it is the layer that checks the path storageState deliberately skips.

The same boundary applies to test users. Autonoma can use its SDK/data factory to create users with specific characteristics or states, authenticate scenarios with those users, and delete the same users after the run. You connect your own create and teardown functions through @autonoma-ai/sdk factories; the SDK pattern uses defineFactory, createHandler, create, and teardown that calls userRepo.delete(user.id). That gives the agent reliable state without claiming native provider-side integrations.

When auth setup breaks: expiry, rot, maintenance

The Playwright authentication patterns above work. The reason they're painful to maintain is that auth state has a shelf life.

JWT sessions expire. By default, most providers issue tokens valid for a day to a week. Your CI pipeline runs on that schedule until one Monday morning the entire suite fails with 401s and the on-call engineer spends two hours figuring out the state file is stale.

Login UIs change. When your product redesigns the login page, the selectors in your setup test break. This is the same fragility you have in any UI test, but it's uniquely disruptive because it takes down every test that depends on auth, not just the login tests.

Stealth dependencies rot. If you're on a stealth-plugin approach for any provider, the plugin may stop working silently when the provider updates its fingerprinting. The tests don't error. They get blocked and time out.

The standard mitigation is a session-validation fixture: before each test run, check whether the saved state is still valid by loading a lightweight authenticated endpoint. If the check returns 401, re-authenticate and refresh the state file.

import { test, request, expect, Page, BrowserContext } from '@playwright/test';
import path from 'path';
import fs from 'fs';

const STORAGE_STATE = path.join(__dirname, '../.auth/user.json');
const BASE_URL      = process.env.BASE_URL ?? 'http://localhost:3000';

/**
 * Session-validation fixture.
 *
 * Before each test, makes a lightweight authenticated request to /api/me.
 * If the response is 401, the saved state is stale (expired JWT or revoked
 * session). The fixture then re-runs the login flow, writes a fresh state
 * file, and hands the test a page loaded with the new session.
 *
 * Use this for:
 *   - Long-running suites where the suite runs longer than the JWT TTL
 *   - Providers that issue short-lived tokens (e.g., 1-hour JWTs)
 *   - Suites that run across multiple days in CI
 *
 * Not needed when:
 *   - Your session TTL is longer than your longest suite run
 *   - You refresh state as a scheduled CI step before the suite starts
 *
 * Run: npx playwright test
 */
export const authedTest = test.extend<{ authedPage: Page }>({
  authedPage: async ({ browser }, use) => {
    let storageState: string | undefined = STORAGE_STATE;

    // Check whether the saved state is still valid.
    const rc = await request.newContext({ storageState, baseURL: BASE_URL });
    const probe = await rc.get('/api/me');
    await rc.dispose();

    if (probe.status() === 401) {
      // State is stale — delete it and re-authenticate via the UI.
      if (fs.existsSync(STORAGE_STATE)) {
        fs.unlinkSync(STORAGE_STATE);
      }

      const setupContext: BrowserContext = await browser.newContext();
      const setupPage = await setupContext.newPage();

      await setupPage.goto(`${BASE_URL}/login`);
      await setupPage.getByLabel('Email').fill(process.env.TEST_EMAIL ?? 'test@example.com');
      await setupPage.getByLabel('Password').fill(process.env.TEST_PASSWORD ?? 'password');
      await setupPage.getByRole('button', { name: 'Sign in' }).click();
      await setupPage.waitForURL(`${BASE_URL}/dashboard`);

      await setupContext.storageState({ path: STORAGE_STATE });
      await setupContext.close();
    }

    // Create the test context with the valid (or freshly-renewed) state.
    const context = await browser.newContext({ storageState: STORAGE_STATE });
    const authedPage = await context.newPage();

    await use(authedPage);

    await context.close();
  },
});

This works. It is also perpetual toil. Every time the login UI changes, you update the setup test. Every time a JWT TTL gets shortened, you adjust the validation interval. Every time a provider updates its bot detection, you revisit the approach. Someone on the team owns this, and it's almost never the person who writes features.

This is where the maintenance burden compounds. You wrote the initial auth setup in an afternoon. You've now spent more time maintaining it than you spent writing it.

FAQ

Use storageState. In your setup test, log in once and call page.context().storageState({ path: '.auth/user.json' }) to save the session. In your playwright.config.ts, point your test project at that file with storageState: '.auth/user.json' and declare a setup project dependency. Every test in that project will start with the saved session loaded, with no login step required.

You can't reliably automate the real Google login flow. Google actively blocks browser automation and headless environments. The reliable approaches are: (1) log in manually once with a dedicated test Google account, save the session with storageState, and store it in CI secrets; (2) mock the OAuth callback so your app receives a fixture identity without hitting Google's servers; or (3) use an auth provider like Clerk, Auth0, or Supabase that offers a test mode bypassing third-party OAuth entirely.

storageState is Playwright's mechanism for serializing and restoring browser session data. When you call page.context().storageState(), Playwright captures the current context's cookies, localStorage, and sessionStorage and writes them to a JSON file. When you load that file in a new context (via the storageState option in the config or context creation), the context starts with all that state pre-loaded, as if the user already completed the login flow.

Create a separate setup test and .auth/ JSON file for each role. For example, admin.setup.ts writes to .auth/admin.json and user.setup.ts writes to .auth/user.json. In playwright.config.ts, define one project per role, each pointing at its own state file with storageState and declaring its own setup project as a dependency. Tests in the admin project start as admin, tests in the user project start as a regular user, and neither can affect the other.

Use API login for your auth setup when your app exposes a login endpoint. It's faster (no page load, no selectors to break) and more stable across UI changes. Use the request fixture to POST credentials and call request.storageState() to capture the session. Reserve UI login for cases where your app's login flow itself is what you're testing, or where the authentication involves UI-only interactions like CAPTCHA or device verification that can't be bypassed via API.

Related articles

Managed vs self-hosted Playwright ownership comparison showing runners, browser upgrades, flake triage, assertions, and control tradeoffs

Managed vs Self-Hosted Playwright: What You Still Own

Managed playwright vs self hosted: compare runner operations, assertion ownership, flake triage, long-term maintenance, and control tradeoffs.

Playwright E2E testing complexity curve showing local setup, CI/CD integration, preview environments, and self-healing as a rising difficulty gradient

Playwright E2E Testing: The Complete Guide from Setup to CI/CD

Complete Playwright E2E testing guide: setup, page objects, fixtures, GitHub Actions CI/CD, preview environments, and best practices for dynamic URLs.

Playwright best practices for 2026: selector hierarchy, retry logic, parallelism, and trace viewer workflows for senior engineers

Playwright Best Practices: 8 Patterns for a Stable 2026 E2E Suite

8 Playwright best practices for 2026: selectors, retries, parallelism, fixtures, trace viewer, CI flakiness, auth state, and POM. Runnable code included.

Auth0 testing diagram: the Resource Owner Password grant flow showing a test runner posting credentials to /oauth/token, receiving an access token, and injecting it into the app under test

How to Test Auth0 Login in an E2E Suite

How to test Auth0 login in E2E tests: use the Resource Owner Password grant to mint an access token directly, inject it into your app, and skip the brittle login UI.