TesterArmyTesterArmy
  • Customers
  • Pricing
Sign inGet started
HomeBlogTesting Authentication in E2E: The Complete Playwright Guide

Testing Authentication in E2E: The Complete Playwright Guide

How to test authentication in e2e without slow, flaky suites: reuse login state with storageState, keep one real login test, handle roles, and know when to stop maintaining it yourself.

Oskar Kwasniewski
Oskar KwasniewskiCTO
June 6, 202614 min read
Testing Authentication in E2E: The Complete Playwright Guide

Authentication is usually the first thing that makes a Playwright suite slow or flaky.

The pattern repeats in most codebases: a beforeEach fills in the login form, the first test passes, and everyone moves on. A few months later the suite takes nine minutes, half the failures are "element not found" on the login page, and nobody trusts a red build.

The fix is not a better selector or a longer timeout. The fix is deciding which tests actually need to log in, and getting every other test out of that business. Playwright has good primitives for this. What is usually missing is a model for how to use them.

Let's walk through a setup that stays fast locally, works in CI, and handles multiple user roles.

What a good auth setup looks like

Before touching any config, it helps to know what we want:

  1. Speed. Logging in through the UI is slow, and most tests have no reason to do it.
  2. Independence. A test should never fail because an earlier test left a user in a weird state.
  3. Honest failures. When auth breaks, the error should say so, not show up as a random assertion failure three pages deep.
  4. Real login coverage, somewhere. The goal is not to stop testing login. The goal is to stop testing it by accident in every dashboard, billing, and settings spec.

That last point is easy to overcorrect on. Once you discover storageState, it is tempting to skip the login UI forever. Don't. You still want one test that proves a user can actually sign in. Playwright's authentication guide covers the mechanics; the rest of this post is the setup around them.

E2E login testing: which flows actually need coverage

Login testing is not one thing. Before writing any code, it helps to separate the four flows that deserve different treatment:

  1. The credential login itself. One test, through the real UI, that proves a user can sign in. This is the test you keep even after everything else uses saved state.
  2. Authenticated journeys. Dashboards, billing, settings. These need a signed-in session but should never touch the login form. This is what storageState is for.
  3. Role boundaries. Admin sees the admin panel, a viewer does not. Save one state file per role and assert both directions: access granted and access denied.
  4. Session expiry and logout. The least tested and the most user-visible when it breaks. One test that logs out and confirms protected pages redirect, and if your app refreshes tokens, one test that survives an expired access token.

If you cover those four deliberately, you can stop testing login by accident in every other spec, which is where most suites lose their speed.

Save the login once with storageState

storageState dumps the cookies and local storage from a signed-in browser context into a JSON file. Any later test can load that file and start already logged in. Sign in once, reuse it everywhere.

One caveat up front: if your app keeps its auth token in IndexedDB (Firebase Auth is the common case), cookies and local storage are not enough, and you need to capture IndexedDB too. More on that below.

Start with a setup test whose only job is to log in and write the state to disk.

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

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

setup("authenticate", async ({ page }) => {
  await page.goto("/sign-in");
  await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();

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

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

For IndexedDB-backed auth, pass the flag when you save:

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

Timing matters here. Many apps finish setting cookies during a redirect, so calling storageState too early saves a half-authenticated context that fails in confusing ways later. Wait for something that only exists after login, like the final URL or a piece of authenticated-only UI, before you save.

Then tell Playwright to run the setup before everything else and point the main browser project at the saved file.

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

export default defineConfig({
  use: {
    baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    {
      name: "setup",
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});

With that in place, your actual tests stop mentioning login at all.

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

test("opens the dashboard", async ({ page }) => {
  await page.goto("/dashboard");

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

This one change removes most of the noise from a suite. The login page can still break, but now it breaks the setup step with a clear message, instead of failing every authenticated test for an unrelated reason.

Keep one real login test

Reusing saved state works until you notice you stopped testing login entirely. If sign-in breaks, that is your whole product locked out, and a suite full of pre-authenticated tests will stay green while it happens.

So keep exactly one test that signs in the way a user would: open the page, type the credentials, click the button.

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

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

test("user can sign in", async ({ page }) => {
  await page.goto("/sign-in");
  await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();

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

The empty storageState is easy to forget. Without it, this test inherits the project's saved session and passes even when the login form is completely broken, which is the opposite of what it should prove.

Now the responsibilities are clean. One test covers the login flow, and the rest of the suite never pays for it. When something fails, you can tell at a glance whether it was login or the feature under test.

Skip the UI when the UI is not the point

There is a second step here: the setup test does not have to use the UI either.

If you control the backend, you probably have a faster way in. A test-only login endpoint, an internal session helper, or an API route that creates a session for a known user. The setup test does not exist to prove the login form works. That is the job of the login test above. It exists to produce a valid authenticated context as quickly and reliably as possible.

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

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

setup("authenticate with API", async ({ browser, request }) => {
  const response = await request.post("/api/test/login", {
    data: {
      email: process.env.E2E_USER_EMAIL,
      password: process.env.E2E_USER_PASSWORD,
    },
  });

  expect(response.ok()).toBe(true);

  await request.storageState({ path: authFile });

  const context = await browser.newContext({ storageState: authFile });
  const page = await context.newPage();
  await page.goto("/dashboard");
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await context.close();
});

This is not always possible. If a third-party identity provider owns your login, you often have to drive the UI. But when the backend is yours, API setup is faster, more stable, and easier to debug. A simple rule: UI login for the login test, API login for everything else.

Give every role its own state file

Real apps rarely have one kind of user. There is an admin, a regular member, a read-only viewer, a billing owner. Each of them sees a different product, and your tests need to be able to become any of them.

Do not share one auth file between roles. Give each role its own.

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

const roles = [
  { name: "admin", email: process.env.E2E_ADMIN_EMAIL, password: process.env.E2E_ADMIN_PASSWORD },
  {
    name: "member",
    email: process.env.E2E_MEMBER_EMAIL,
    password: process.env.E2E_MEMBER_PASSWORD,
  },
] as const;

for (const role of roles) {
  setup(`authenticate ${role.name}`, async ({ page }) => {
    await page.goto("/sign-in");
    await page.getByLabel("Email").fill(role.email!);
    await page.getByLabel("Password").fill(role.password!);
    await page.getByRole("button", { name: "Sign in" }).click();

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

    await page.context().storageState({
      path: `playwright/.auth/${role.name}.json`,
    });
  });
}

Then each test opts into the role it needs.

tests/billing.spec.ts
import { expect, test } from "@playwright/test";

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

test("admin can open billing settings", async ({ page }) => {
  await page.goto("/dashboard/settings/billing");

  await expect(page.getByRole("heading", { name: "Billing" })).toBeVisible();
});

Keeping roles explicit catches a sneaky class of bug: the viewer test that only passes because it accidentally ran with an admin session. If permissions regress, that test should go red, not keep passing for the wrong reason.

Some flows need two users in the same test, like an admin inviting a member. For those, create separate contexts from separate state files.

tests/invites.spec.ts
import { expect, test } from "@playwright/test";

test("admin can invite a member", async ({ browser }) => {
  const adminContext = await browser.newContext({
    storageState: "playwright/.auth/admin.json",
  });
  const memberContext = await browser.newContext({
    storageState: "playwright/.auth/member.json",
  });

  const adminPage = await adminContext.newPage();
  const memberPage = await memberContext.newPage();

  await adminPage.goto("/dashboard/settings/members");
  await memberPage.goto("/dashboard");

  await expect(adminPage.getByRole("heading", { name: "Members" })).toBeVisible();
  await expect(memberPage.getByRole("heading", { name: "Dashboard" })).toBeVisible();

  await adminContext.close();
  await memberContext.close();
});

Do not let parallel workers share an account

This failure mode usually shows up after you speed the suite up with more workers: every worker is signed in as the same account.

When two tests share a user, they share that user's data. One test changes a setting while another reads it mid-change, and the build goes red for reasons that have nothing to do with the code. It only happens under parallelism, so it passes locally and fails in CI.

There are three ways out:

  1. Give each worker its own account.
  2. Create a fresh account per test.
  3. Keep shared accounts strictly read-only.

For CI, the first option is usually the right trade-off. Pre-create a small pool of test users and hand one to each worker through a fixture.

tests/fixtures.ts
import { expect, test as base } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";

type WorkerFixtures = {
  workerStorageState: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),
  workerStorageState: [
    async ({ browser }, use) => {
      const index = test.info().parallelIndex;
      const authFile = path.resolve(test.info().project.outputDir, `.auth/${index}.json`);

      if (fs.existsSync(authFile)) {
        await use(authFile);
        return;
      }

      const page = await browser.newPage({ storageState: undefined });

      await page.goto("/sign-in");
      await page.getByLabel("Email").fill(process.env[`E2E_USER_${index}_EMAIL`]!);
      await page.getByLabel("Password").fill(process.env[`E2E_USER_${index}_PASSWORD`]!);
      await page.getByRole("button", { name: "Sign in" }).click();

      await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
      await page.context().storageState({ path: authFile });
      await page.close();

      await use(authFile);
    },
    { scope: "worker" },
  ],
});

This writes one state file per worker into the project's output directory, so it never touches committed files and gets cleaned up with the rest of the test artifacts.

You do not need this on day one. But if the suite is green locally and flaky only in CI, shared users are the first thing to check.

Fail loudly when auth goes stale

Saved auth does not stay valid forever. Tokens rotate, session policies change, a provider shortens cookie lifetimes, and one morning every test fails because the saved state file is logged out.

The trap is letting that surface deep inside an unrelated test, where the symptom reads as "the dashboard didn't load" instead of "auth expired". So make every setup verify it actually worked before handing the state off.

tests/auth.setup.ts
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();

If your app redirects logged-out users to /sign-in, do not just wait for navigation to settle. Assert the authenticated state you expect to see. "Did not get redirected" and "is actually signed in" are different checks, and only the second one catches a silently expired session.

In CI, treat auth files as disposable.

.gitignore
playwright/.auth/

Generate the state fresh on every run and keep credentials in your CI secret store. The files are throwaway artifacts, so treat them that way.

Common mistakes

The same patterns come up again and again:

  • Logging in through the UI before every test. Slow, and it turns one broken login into a hundred broken tests.
  • Not testing login at all. The opposite extreme. If users cannot sign in, nothing else matters, so keep the one real login test.
  • Sharing a single account across parallel workers. Fine until tests start writing data, then mysteriously red in CI.
  • Running logged-out tests with a saved session. Public pages and sign-up flows need an empty state, or they pass for the wrong reason.
  • Hiding auth setup in a shell script before npx playwright test. A setup project lives inside the runner, so it is traced, reported, and debuggable like any other test. An invisible pre-step is none of those things.

Where TesterArmy fits

If you maintain your own Playwright suite, everything above is a solid baseline. It is roughly what we would set up ourselves.

We built TesterArmy because past a certain point, hand-maintaining auth fixtures, role pools, screenshots, videos, retries, and reporting for your most critical flows stops being a good use of anyone's time. TesterArmy runs those flows from plain-language steps and gives you the evidence when one breaks: screenshots, video, logs, and a readable summary of what went wrong.

It is most useful for the journeys you cannot afford to ship broken:

  • Sign in
  • Complete onboarding
  • Invite a teammate
  • Change billing settings
  • Submit a checkout
  • Verify a PR preview before it merges

None of this means dropping Playwright. Keep it for the low-level coverage it is great at, and let TesterArmy run the high-level journeys and report back.

When you would rather not maintain any of this

Everything above is the honest cost of doing auth well in a code-based suite: setup projects, state files per role, IndexedDB flags, expiry edge cases. It works, and if you run Playwright it is the right way to do it.

There is also a way to not own that machinery. TesterArmy's agent handles authentication as part of the run: credential logins, OAuth redirects, and one-time codes delivered by email or SMS, using managed test inboxes and phone numbers, with no mocks and no state files to keep fresh. Environment gates in front of the app, like staging behind HTTP basic auth, are handled the same way, as configuration. The setup is a URL and a plain-English description of the flow. How that works is documented in the auth section of our docs, and the case where login leaves your app entirely has its own guide: SSO and email auth in e2e.

That's a wrap

Test authentication directly, in one place, and stop re-testing it by accident in every other spec.

Use storageState for the bulk of your authenticated tests. Keep a single UI login test so you know sign-in still works. Split roles into separate state files, do not let parallel workers share one account, and make stale auth fail in setup rather than halfway through something unrelated.

The result is a faster suite, readable failures, and a CI you can trust.

If you want to take this up to the pull-request level next, read Run E2E Tests on Vercel Preview Deployments.

Frequently asked questions

How do you test authentication in e2e tests? Log in through the real UI exactly once in a setup step, save the session with storageState, and start every other test already authenticated. Keep one dedicated login test so the sign-in flow itself stays covered.

Why are login tests flaky? Because the login flow involves redirects, cookies set during navigation, and sometimes third-party providers. Saving state too early, before the app finishes setting cookies, is the most common cause of confusing downstream failures. The wider Playwright diagnosis is in why your Playwright tests are flaky.

Should every e2e test log in through the UI? No. UI login in every test is slow and multiplies the blast radius of any login-page change. One real login test plus reused state for everything else is the standard pattern.

ON THIS PAGE

  • What a good auth setup looks like
  • E2E login testing: which flows actually need coverage
  • Save the login once with storageState
  • Keep one real login test
  • Skip the UI when the UI is not the point
  • Give every role its own state file
  • Do not let parallel workers share an account
  • Fail loudly when auth goes stale
  • Common mistakes
  • Where TesterArmy fits
  • When you would rather not maintain any of this
  • That's a wrap
  • Frequently asked questions

SHARE THIS ARTICLE

  • X

Check other TesterArmy insights

August 26, 2026

Introducing unbox-ai: See Where Your Agent's Tokens Actually Go

We built unbox-ai to read our own QA agent's traces. One command opens a token treemap, a latency waterfall, and a scrubbable timeline of the run. The same binary is a read-only trace explorer your coding agent can use. Now it's open source.

Read article
August 20, 2026

Introducing the Issues Tab: One Row Per Bug, No Matter How Many Runs Found It

Agent-found bugs now land in one deduplicated list instead of scattered run reports. Every issue carries expected vs actual, repro steps, and a replay of the exact moment it broke - and leaves the list as a Linear ticket, a prompt for your coding agent, or resolved.

Read article
August 18, 2026

We Benchmarked 10 Vision Models on Clicking What They See

All ten models answered every visual understanding question correctly. Grounding the click is where the field splits, and where GPT-5.6 Luna won on accuracy, precision, and cost.

Read article
Contact us

Let's connect

Contact usGet a demo
XLinkedInDiscord
TesterArmyTesterArmy

AI-powered QA testing for modern teams. Ship faster with confidence.

SOC 2 Type 2 badge
GDPR badge

© 2026 TesterArmy, Inc.

Solutions
  • AI app testingAI app testing
  • EcommerceEcommerce
  • Expo app testingExpo app testing
  • MobileMobile
  • Production monitoringProduction monitoring
  • React Native testingReact Native testing
  • WebWeb
  • WordPress testingWordPress testing
Quick links
  • HomeHome
  • DemoDemo
  • FeaturesFeatures
  • How it worksHow it works
  • FAQFAQ
  • PricingPricing
  • Get a demoGet a demo
  • About usAbout us
  • Contact usContact us
Resources
  • DocumentationDocumentation
  • Migrate to TesterArmyMigrate to TesterArmy
  • Recruit a FriendRecruit a Friend
  • Affiliate programAffiliate program
  • BlogBlog
  • Open sourceOpen source
  • CustomersCustomers
  • API referenceAPI reference
  • Getting startedGetting started
Legal
  • Privacy policyPrivacy policy
  • Terms of serviceTerms of service
TesterArmyTesterArmy
  • Customers
  • Pricing
Sign inGet started
Quick links
  • HomeHome
  • DemoDemo
  • FeaturesFeatures
  • How it worksHow it works
  • FAQFAQ
  • PricingPricing
  • Get a demoGet a demo
  • About usAbout us
  • Contact usContact us