Core Concepts October 12, 2026 6 min read

Playwright Authentication & storageState: Log In Once, Reuse Everywhere

Logging in through the UI before every test is the single biggest waste of time in most Playwright suites. With a setup project and storageState, you log in once, save the session to a file, and start every test already authenticated. This guide covers the official setup, multiple roles, API login, MFA, and every reason storageState stops working.

Every Playwright test runs in a fresh, isolated browser context. That isolation is what makes tests reliable — but it also means each test starts logged out. If 200 tests each spend 3 seconds on a login form, you lose 10 minutes per run, and every one of those logins is another chance for a flaky failure.

Playwright's answer is storageState: a JSON snapshot of cookies and local storage that you can save after logging in and load into any new context.


Why Log In Only Once?

  • Speed — the login runs once per test run instead of once per test.
  • Less flakiness — fewer runs through the login UI, captchas, redirects and rate limits.
  • Cleaner tests — specs focus on the feature, not on typing a password.
  • Still isolated — each test gets its own context; they just share a starting session.

Step by Step: Setup Project + storageState

This is the pattern recommended in the official Playwright docs. First, create a folder for auth files and keep it out of git:

Terminal
mkdir -p playwright/.auth
echo $'\nplaywright/.auth' >> .gitignore

Next, write a setup “test” that logs in and saves the state:

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('https://github.com/login');
  await page.getByLabel('Username or email address').fill('username');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  // Wait until the login has really finished before saving
  await page.waitForURL('https://github.com/');
  await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

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

Finally, register the setup project and make your browser projects depend on it:

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'],
    },
  ],
});

Now npx playwright test runs the setup project first, then every Chromium test starts already signed in. Because setup runs on each test run, the session is always fresh.

Never hard-code real credentials. Read them from process.env and set them as CI secrets, as shown in our GitHub Actions guide.

What Is Inside the storageState JSON?

The saved file is plain JSON with two top-level keys:

playwright/.auth/user.json (shape)
{
  "cookies": [
    { "name": "session_id", "value": "...", "domain": "example.com",
      "path": "/", "expires": 1767225600, "httpOnly": true,
      "secure": true, "sameSite": "Lax" }
  ],
  "origins": [
    { "origin": "https://example.com",
      "localStorage": [ { "name": "token", "value": "..." } ] }
  ]
}

Because it contains live session cookies and tokens, treat this file like a password — that is why it goes in .gitignore.

Multiple Users and Roles

Admin-vs-user tests are common. Save one file per role in the setup project:

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

const adminFile = 'playwright/.auth/admin.json';
setup('authenticate as admin', async ({ page }) => {
  await page.goto('https://github.com/login');
  await page.getByLabel('Username or email address').fill('admin');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('https://github.com/');
  await page.context().storageState({ path: adminFile });
});

const userFile = 'playwright/.auth/user.json';
setup('authenticate as user', async ({ page }) => {
  // same steps with the regular user account
  await page.context().storageState({ path: userFile });
});

Then pick the role per file or per describe block:

tests/roles.spec.ts
import { test } from '@playwright/test';

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

test('admin test', async ({ page }) => {
  // page is authenticated as admin
});

test.describe(() => {
  test.use({ storageState: 'playwright/.auth/user.json' });
  test('user test', async ({ page }) => {
    // page is authenticated as a user
  });
});

If a single test needs both users at once (for example, chat between admin and user), create two contexts inside the test with browser.newContext({ storageState: ... }). For reusable role setups, wrap them in custom fixtures.

Faster: Log In via API Instead of the UI

If your app has a login endpoint, skip the browser entirely in setup. The request fixture shares the same storage format:

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

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

setup('authenticate', async ({ request }) => {
  await request.post('https://github.com/login', {
    form: { user: 'user', password: 'password' },
  });
  await request.storageState({ path: authFile });
});

API login is usually much faster and avoids UI flakiness. Keep one UI login test elsewhere so the login page itself is still covered — see our login test tutorial and API testing guide.

Session Storage and IndexedDB

By default storageState saves cookies and localStorage only. Two common gaps:

sessionStorage

Session storage is not persisted. If your app keeps its token there, save and restore it yourself:

Save and restore sessionStorage
import fs from 'fs';

// after login: save
const sessionStorage = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('playwright/.auth/session.json', sessionStorage, 'utf-8');

// in a new context: restore before the app loads
const saved = JSON.parse(fs.readFileSync('playwright/.auth/session.json', 'utf-8'));
await context.addInitScript(storage => {
  if (window.location.hostname === 'example.com') {
    for (const [key, value] of Object.entries(storage))
      window.sessionStorage.setItem(key, value as string);
  }
}, saved);

IndexedDB (Firebase and similar)

Some auth libraries, such as Firebase Auth, store the session in IndexedDB. Ask Playwright to include it when saving:

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

Handling MFA and OTP Logins

  • Best option: use a dedicated test account with MFA disabled in your test environment.
  • TOTP apps: store the account's TOTP secret as a CI secret and generate the code in setup with a library such as otpauth.
  • Email/SMS codes: read the code from a test inbox or SMS API, or ask the backend team for a test-only bypass.
  • Either way, MFA runs once in the setup project — not in every test.
TOTP in auth.setup.ts
import * as OTPAuth from 'otpauth';

const totp = new OTPAuth.TOTP({ secret: process.env.TOTP_SECRET! });
await page.getByLabel('Authentication code').fill(totp.generate());
await page.getByRole('button', { name: 'Verify' }).click();

storageState Not Working? 7 Fixes

SymptomLikely cause & fix
Tests start logged outThe browser project is missing dependencies: ['setup'], or storageState is set on the wrong project.
ENOENT — file not foundPaths in config are relative to the working directory. Run from the project root, or build the path with path.join(__dirname, ...).
Setup never runstestMatch for the setup project does not match your file name, or the file is outside testDir.
Works locally, fails in CIplaywright/.auth is ignored by git (correct!) — make sure CI runs the setup project rather than expecting a committed file.
Logged in on one URL but not anotherCookie domain mismatch: localhost and 127.0.0.1 are different origins. Use one consistent baseURL.
Session lost after reloadThe token lives in sessionStorage, IndexedDB or memory — see the section above.
Logged out halfway through a long runThe session expired. Shorten the run with sharding, or extend the test account's session lifetime.

Frequently Asked Questions

Where should the storageState file be saved?

The Playwright docs use playwright/.auth/user.json. Any path works, as long as the setup project and the config point to the same file and the folder is in .gitignore.

Should I commit the playwright/.auth folder to git?

No. It contains live session cookies and tokens. Add playwright/.auth to .gitignore and let the setup project create it on every run, including in CI.

How do I refresh an expired storageState session?

With a setup project and dependencies, Playwright logs in again at the start of every test run, so the session is always fresh. Expiry only matters if a single run lasts longer than the session lifetime.

Can I use storageState with multiple users?

Yes. Save one file per role in the setup project, then select it with test.use({ storageState: ... }) per file or per describe block, or create several contexts in one test.

Does storageState work in Playwright Python?

Yes. Call context.storage_state(path="state.json") after login and pass storage_state="state.json" to browser.new_context(). With pytest-playwright you can override the browser_context_args fixture to load it.


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

Complete Course

Build a Production-Ready Playwright Framework

Authentication, fixtures, page objects and CI are where most test suites fall apart. The complete Udemy course walks through all of them with real projects — plus AI-powered test generation with Claude.

  • Auth setup with storageState
  • Fixtures and page objects
  • API + UI testing together
  • CI/CD with GitHub Actions
Enroll Now on Udemy →