Playwright
Playwright Authentication: Log In Once and Reuse It Safely
Four ways to handle login in Playwright, when each one breaks, and how to keep credentials and state files out of git.

Good Playwright authentication means your tests start signed in without typing a password hundreds of times. Playwright gives you the tools: a setup project that logs in once, and storageState, a JSON snapshot of cookies and local storage that later tests load. The hard part is picking the right pattern. Choose wrong and you get a new kind of flake: tests that pass alone and fail together because they share one account.
This guide covers four patterns, when to use each and how each fails. Every example was run against a small local demo app with @playwright/test 1.61 on 22 October 2026. Download the examples to run them yourself: one small Playwright project per pattern, plus the demo app (plain Node, no dependencies) and a README.
TL;DR: pick a Playwright authentication pattern
| Pattern | Use it when | It breaks when |
|---|---|---|
| 1. Setup project + one state file | Tests read data, or write data no other test reads | Parallel tests change the same account's settings; a test signs out and the server revokes the shared session |
| 2. One state file per role | You test permissions (admin vs member) | The same as 1, once per role |
| 3. One account per parallel worker | Tests change account data: profile, cart, preferences | You shard, or two pipelines share one environment, and account names collide |
| 4. Log in through the API | The login endpoint returns a session cookie | The front end stores the token itself (localStorage, sessionStorage, IndexedDB), or the login needs MFA or SSO |
Key takeaways:
- Keep credentials in environment variables and state files in a git-ignored
playwright/.authfolder. storageStatesaves cookies and localStorage. It does not save sessionStorage. It saves IndexedDB only when you passindexedDB: true.- A state file is a live session. Treat it like a password until it expires.
Why logging in through the UI in every test hurts
Logging in inside each test looks harmless. It costs you in three ways.
First, it is slow. Every test pays for a page load, a form submit and a redirect chain before it does anything useful.
Second, it hides real failures. If the login page breaks, every test fails at the same first step. You see 300 red tests and no clue about which features still work.
Third, it trips the app's own defences. Login endpoints often have rate limits, lockouts after failed attempts and bot checks. A suite that logs in 500 times in two minutes can trigger them.
Playwright runs every test in a fresh browser context, which is like a new browser profile. A context can start from a saved state, though. Log in once, save the state, and every later context starts signed in. Your login page still gets its own tests, written on purpose.
Pattern 1: a setup project and storageState
This is the pattern the official authentication guide recommends for most suites. A project named setup runs first and saves the state. Your browser projects depend on it and load the file.
Create the folder and keep it out of git before anything else:
mkdir -p playwright/.auth
echo "playwright/.auth" >> .gitignoreThen the config:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:4174',
},
projects: [
// Runs first: logs in and writes playwright/.auth/user.json.
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: 'node ../app/server.mjs',
url: 'http://localhost:4174/health',
reuseExistingServer: !process.env.CI,
},
});The webServer command starts the demo app from the download (app/server.mjs). In your project, replace it with your own app's start command, or remove the block if the app is already running.
The setup file reads credentials from the environment and fails with a clear message when they are missing:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running the tests (see .env.example).`);
return value;
}
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(requireEnv('TEST_EMAIL'));
await page.getByLabel('Password').fill(requireEnv('TEST_PASSWORD'));
await page.getByRole('button', { name: 'Sign in' }).click();
// The login sets its cookie during a redirect chain. Wait for the page that
// proves you are signed in before saving, or you may save a half-done state.
await page.waitForURL('/dashboard');
await expect(page.getByText(`Signed in as ${process.env.TEST_EMAIL}`)).toBeVisible();
await page.context().storageState({ path: authFile });
});The wait before saving matters. Many login flows set cookies across several redirects. If you call storageState() while a callback page is still loading, you save a state that is not signed in yet. The demo app adds a callback hop on purpose, and the waitForURL line is what makes the setup reliable.
Now your tests have no login code at all:
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
// No login code: the chromium project loads playwright/.auth/user.json.
test('dashboard greets the signed-in user', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Signed in as')).toBeVisible();
});
test('profile page opens without a login step', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
});Run it with the variables set:
TEST_EMAIL=member@example.test TEST_PASSWORD=demo-password-123 npx playwright testThose values are a throwaway account seeded in the demo app, and they only work against it. In your project, use a dedicated test account and load its email and password from a .env file or your CI secret store.

A few behaviours worth knowing:
- A failed login stops the run early. Without
TEST_EMAIL, the setup fails withError: Set TEST_EMAIL before running the tests (see .env.example).and the report lists the other tests as "did not run". You get one clear failure, not a wall of login errors. - The setup runs on every
npx playwright test. CI always starts from a fresh login. Two exceptions:--no-depsskips dependencies, and UI mode skips the setup project by default to keep runs fast, so runauth.setup.tsyourself from the UI when the saved session expires. Both can load an old file, so deleteplaywright/.authwhen tests start landing on the login page. - Keep one host. Cookies belong to a domain. If the setup logs in on
localhostand the tests open127.0.0.1, the saved cookie is never sent. UsebaseURLeverywhere. - Prefer this over
globalSetup. The global setup docs list why: a setup project shows up in the HTML report, records traces, and can use fixtures and config options. AglobalSetupfunction gets none of that.
Tests that must start signed out
The login page itself needs tests, and they cannot load the saved state. Override it with an empty state for that file:
// tests/login-page.spec.ts
import { test, expect } from '@playwright/test';
// This file tests the login page itself, so it must start signed out.
test.use({ storageState: { cookies: [], origins: [] } });
test('signed-out visitor is sent to the login page', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login$/);
});
test('wrong password shows an error', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('member@example.test');
await page.getByLabel('Password').fill('not-the-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Wrong email or password');
});Pattern 2: several roles (admin and member)
Permission bugs hide in the gap between roles, so test each role with its own account. Log in once per role in the setup, and save one file per role:
// tests/auth.setup.ts
import { test as setup, expect, type Page } from '@playwright/test';
const ADMIN_STATE = 'playwright/.auth/admin.json';
const MEMBER_STATE = 'playwright/.auth/member.json';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running the tests (see .env.example).`);
return value;
}
async function logIn(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByText(`Signed in as ${email}`)).toBeVisible();
}
setup('authenticate as admin', async ({ page }) => {
await logIn(page, requireEnv('ADMIN_EMAIL'), requireEnv('ADMIN_PASSWORD'));
await page.context().storageState({ path: ADMIN_STATE });
});
setup('authenticate as member', async ({ page }) => {
await logIn(page, requireEnv('MEMBER_EMAIL'), requireEnv('MEMBER_PASSWORD'));
await page.context().storageState({ path: MEMBER_STATE });
});Remove storageState from the project in the config (keep dependencies: ['setup']). Then each spec file picks its role:
// tests/member.spec.ts
import { test, expect } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/member.json' });
test('member does not see the admin link', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('link', { name: 'Admin' })).toHaveCount(0);
});
test('member is refused on the admin page', async ({ page }) => {
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'You do not have access to this page' })).toBeVisible();
});Some tests need both roles at once. One example: an admin changes a permission and a member sees the effect. Give each role its own context:
// tests/two-roles.spec.ts
import { test, expect } from '@playwright/test';
// One test, two signed-in users: each gets its own browser context.
test('admin and member see different navigation', async ({ browser }) => {
const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
const memberContext = await browser.newContext({ storageState: 'playwright/.auth/member.json' });
try {
const adminPage = await adminContext.newPage();
const memberPage = await memberContext.newPage();
await adminPage.goto('/dashboard');
await memberPage.goto('/dashboard');
await expect(adminPage.getByRole('link', { name: 'Admin' })).toBeVisible();
await expect(memberPage.getByRole('link', { name: 'Admin' })).toHaveCount(0);
} finally {
await adminContext.close();
await memberContext.close();
}
});A context you create inside a test still gets the config's use options, so the relative goto('/dashboard') resolves against baseURL. Close the contexts yourself. A finally block makes sure a failed assertion doesn't leave them open.
Pattern 3: one account per parallel worker
Patterns 1 and 2 share one account across every parallel test. That is fine until tests write to it. Take two tests that run at the same time. One renames the profile to "Ada", the other to "Grace". Each then checks that its own name is shown. Sooner or later one of them reads the other's write and fails. Rerun it alone and it passes, so it gets labelled flaky.
The fix is one account per worker. A worker is one Playwright process, and it runs one test at a time. Give each worker its own account and no two running tests share data. This version builds on the official worker example:
// playwright/fixtures.ts
import { test as baseTest, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
export * from '@playwright/test';
// One test account per parallel worker. The accounts must already exist in the
// app under test (seeded): qa-<shard>-<worker>@example.test, all with the
// password in TEST_WORKER_PASSWORD.
function accountFor(shard: number, parallelIndex: number) {
const password = process.env.TEST_WORKER_PASSWORD;
if (!password) throw new Error('Set TEST_WORKER_PASSWORD before running the tests (see .env.example).');
return { email: `qa-${shard}-${parallelIndex}@example.test`, password };
}
export const test = baseTest.extend<{}, { workerStorageState: string }>({
// Every test in this worker uses the worker's state file.
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [
async ({ browser }, use, workerInfo) => {
const id = workerInfo.parallelIndex;
const shard = workerInfo.config.shard?.current ?? 1;
const fileName = path.resolve(workerInfo.project.outputDir, `.auth/${shard}-${id}.json`);
// A worker restarted after a failure keeps its parallelIndex: reuse its file.
if (fs.existsSync(fileName)) {
await use(fileName);
return;
}
// Start from an empty state, not from whatever the project config sets.
// A page made in a worker fixture does not get `use` options such as
// baseURL, so pass it yourself.
const page = await browser.newPage({
storageState: undefined,
baseURL: workerInfo.project.use.baseURL,
});
const account = accountFor(shard, id);
await page.goto('/login');
await page.getByLabel('Email').fill(account.email);
await page.getByLabel('Password').fill(account.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByText(`Signed in as ${account.email}`)).toBeVisible();
await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
},
{ scope: 'worker' },
],
});Tests import test and expect from this file instead of @playwright/test:
// tests/profile.spec.ts
import { test, expect } from '../playwright/fixtures';
// Each test renames the signed-in account. With one shared account these
// tests would overwrite each other's name when they run in parallel.
for (const name of ['Ada', 'Grace', 'Linus', 'Margaret', 'Ken', 'Barbara']) {
test(`rename the profile to ${name}`, async ({ page }) => {
await page.goto('/profile');
await page.getByLabel('Display name').fill(name);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(`Display name: ${name}`)).toBeVisible();
});
}The config needs no setup project for this pattern. Two details in the fixture differ from the docs example, and both came from running it:
baseURLis not applied inside a worker fixture.baseURLis a test-scoped option, and the page here is created at worker scope. Without the explicitbaseURL, every test failed withpage.goto: Protocol error (Page.navigate): Cannot navigate to invalid URL.parallelIndexstarts at 0 on every machine. With sharding, shard 1 and shard 2 both have a worker 0. Both would log in as the same account and you are back to shared data. Addingconfig.shard.currentto the key fixes that. Running with--shard=2/2wrote2-0.json,2-1.jsonand2-2.json.
Two pipelines that test the same staging environment at the same time collide in the same way. Add a pipeline ID to the account key, or lease accounts from a small pool in your test API. Either way, the accounts have to exist before the run. Seeding them is a test data job, not a login job.
The state files live in the project's outputDir (test-results by default). Playwright cleans that folder at the start of each run, so you never reuse yesterday's session by accident.
Pattern 4: log in through the API instead of the form
If the app has a login endpoint that answers with a Set-Cookie header, skip the browser in the setup. The request fixture keeps its own cookie jar and can save it in the same format:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running the tests (see .env.example).`);
return value;
}
setup('authenticate through the API', async ({ request }) => {
const response = await request.post('/api/login', {
data: { email: requireEnv('TEST_EMAIL'), password: requireEnv('TEST_PASSWORD') },
});
await expect(response).toBeOK();
// The response's Set-Cookie header is now in the request context's cookie jar.
await request.storageState({ path: authFile });
});The config and the tests are the same as in pattern 1. This is the fastest setup, and it does not depend on the login page's markup. It also has limits:
- It saves only what the server set. If the endpoint returns a token in JSON and the front end puts it in localStorage, the request context never sees localStorage. You would have to write the
originsentry of the state file yourself, which couples your tests to how the front end stores the token. - CSRF protection. Many apps want a CSRF token with the login POST. Fetch the page or endpoint that issues it first, using the same
request. - MFA and SSO usually rule it out, unless your test environment has an API route for test sign-in.
Apps that keep the session in sessionStorage
Some single-page apps keep the access token in sessionStorage. Playwright's storageState does not save it. The authentication guide says so, and it is easy to confirm. I loaded the saved state file into a fresh context, and the demo app showed its sign-in form again.
Save sessionStorage yourself in the setup:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
const authFile = 'playwright/.auth/user.json';
const sessionFile = 'playwright/.auth/session.json';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running the tests (see .env.example).`);
return value;
}
setup('authenticate (token in sessionStorage)', async ({ page }) => {
await page.goto('/spa');
await page.getByLabel('Email').fill(requireEnv('TEST_EMAIL'));
await page.getByLabel('Password').fill(requireEnv('TEST_PASSWORD'));
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Signed in as')).toBeVisible();
// Cookies and localStorage, as usual. The token is NOT in here.
await page.context().storageState({ path: authFile });
// sessionStorage has to be saved by hand.
const session = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync(sessionFile, session, 'utf-8');
});Then put it back before the app's own scripts run. An init script does that. Wrap it in a fixture so no test can forget it:
// playwright/fixtures.ts
import { test as base } from '@playwright/test';
import fs from 'node:fs';
export * from '@playwright/test';
const sessionFile = 'playwright/.auth/session.json';
export const test = base.extend({
// Put the saved sessionStorage back before any page script runs.
context: async ({ context, baseURL }, use) => {
const saved = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as Record<string, string>;
const appOrigin = new URL(baseURL!).origin;
await context.addInitScript(
({ entries, origin }) => {
// Init scripts run in every page and frame: only touch your app's origin.
if (window.location.origin !== origin) return;
for (const [key, value] of Object.entries(entries)) {
window.sessionStorage.setItem(key, value);
}
},
{ entries: saved, origin: appOrigin },
);
await use(context);
},
});The origin check is not optional. Init scripts run in every frame, including third-party iframes such as a payment form or a chat widget. Without the check you would copy your token into their storage.
Apps built on Firebase Authentication and similar libraries keep tokens in IndexedDB instead. storageState() takes an indexedDB: true option for that, added in Playwright 1.51 (API reference). Without the option, IndexedDB is left out. I checked both cases on 1.61.1: a token written to IndexedDB was missing from the default state and present with indexedDB: true (folder 07-indexeddb in the example download).
Expiry, sign-out, MFA and single sign-on
Expiry. A state file holds a session, and sessions end. With a setup project, a fresh login happens on every run, so expiry bites in two places. One is a stale local file loaded through --no-deps or UI mode; delete playwright/.auth and log in again. The other is a suite that runs longer than the session lifetime. For that, ask for a longer session lifetime in the test environment only. Logging in per worker (pattern 3) also helps a little, because each worker logs in when it starts rather than all of them at the start of the run.
Sign-out tests. Many apps revoke the session on the server when you sign out. If a test signs out using the shared state, it kills the cookie that every other test is using. The demo app revokes sessions this way. When one context signed out with the shared state file, a second context that loaded the same file landed on the login page. The fix is for any test that signs out to start signed out and log in for itself:
// tests/logout-safe.spec.ts
import { test, expect } from '@playwright/test';
// The fix: a test that signs out starts signed out and logs in for itself,
// so it only revokes its own session.
test.use({ storageState: { cookies: [], origins: [] } });
test('sign out ends the session', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL(/\/login$/);
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login$/);
});The same applies to "change password" tests, since some apps end every other session when the password changes.
MFA. The cleanest option is a test environment where the test accounts don't need a second factor. If your security team won't allow that, use a TOTP factor. Store the account's TOTP secret as a CI secret and compute the code in the setup project, the way an authenticator app does. The calculation is short enough to need no dependency:
// tests/totp.ts
import crypto from 'node:crypto';
// RFC 6238 time-based one-time password (SHA-1, 6 digits, 30-second step),
// the kind an authenticator app shows. `secret` is the base32 key from the QR code.
function base32ToBuffer(secret: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const char of secret.replace(/=+$/, '').replace(/\s/g, '').toUpperCase()) {
const index = alphabet.indexOf(char);
if (index === -1) throw new Error('The TOTP secret is not valid base32.');
bits += index.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(bytes);
}
export function totp(secret: string, now = Date.now()): string {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(now / 30_000)));
const hmac = crypto.createHmac('sha1', base32ToBuffer(secret)).update(counter).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const code = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
return code.toString().padStart(6, '0');
}In the setup, after the password step (a fragment; the full setup is in 06-mfa/tests/auth.setup.ts in the download):
await page.getByLabel('Authentication code').fill(totp(requireEnv('MFA_TOTP_SECRET')));
await page.getByRole('button', { name: 'Verify' }).click();I checked this helper against four of the SHA-1 test vectors in RFC 6238, cut to six digits, and all four matched. Some servers refuse the same code twice. If several setups log in with one MFA account inside one 30-second window, give each its own account. SMS and email codes are harder to automate. Keep them out of the automated path, and test them on purpose with a mail or SMS sandbox.
Single sign-on. Run the identity-provider login once, in the setup project, against the provider's test tenant. After the redirect back, your app's own session cookie is usually all the tests need. Don't drive a production identity provider's sign-in page on every run. You don't control its markup, and it may challenge automated sign-ins with checks your tests cannot pass.
Keep credentials and state files out of git
- Credentials come from environment variables: a git-ignored
.envfile locally, and your CI's secret store in pipelines. Commit a.env.examplethat lists the names with empty values. Playwright environment variables covers loading them per environment. - State files go in
playwright/.auth, which is git-ignored. The official guide warns that anyone holding this file can act as the test account. Never commit it to "speed up CI". - CI artifacts. Pattern 3 writes state files into
test-results, and many pipelines upload that whole folder. Excludetest-results/**/.authfrom the upload, or write the files somewhere you don't upload. HAR files and traces record request headers too, so treat them as sensitive. - Test accounts should be dedicated, low-privilege and limited to test environments. If a state file leaks, revoke the account's sessions and rotate its password.
A typical .gitignore for a Playwright project:
node_modules/
.env
playwright/.auth
test-results/
playwright-report/How Blendra treats sign-in in recorded tests
Blendra records end-to-end tests from your own Chrome session and exports them as a Playwright project. Many recorded flows start with a sign-in, so the same rules apply. The recorder follows a flow across sites, including a single sign-on hop to an identity provider. It drops the values typed into password, card, one-time-code and token fields in the browser, before a step is stored. The exported test reads each one from an environment variable such as TEST_PASSWORD, and .env.example lists the names. If a variable is missing, the test fails before it fills a blank field, with an error that names the variable. See Sensitive values for what is detected, and Diagnose failures for how Blendra labels a run whose test account was signed out or whose password was not set.
Two honest limits. Exported tests replay the sign-in steps you recorded; Blendra does not generate a storageState setup project today. The export is a plain Playwright project, so you can add pattern 1 yourself. And exported tests run in your own CI, where you set the secrets as CI variables; Blendra does not store recorded secrets.
FAQ
Does storageState include IndexedDB?
Only if you ask. context.storageState({ path, indexedDB: true }) includes it, and request.storageState() takes the same option. Both were added in Playwright 1.51. By default you get cookies and localStorage only. sessionStorage is never included, so save it as shown above.
How do I test the login page itself?
Start that file signed out with test.use({ storageState: { cookies: [], origins: [] } }). Then test the real flows on purpose: wrong password, locked account, password reset, redirect back to the page you asked for. A few focused tests here beat running the login form before every test.
What about "Sign in with Google" and other OAuth popups?
If the provider opens a popup, start waiting for it before you click: const popupPromise = page.waitForEvent('popup'), then click, then const popup = await popupPromise. Drive the popup like any page. Do this once, in the setup project, against a test tenant. Better still, give the test environment a way to sign in without the provider, and keep one or two tests for the real OAuth path. When that login breaks in CI, the setup project's trace shows why; the Playwright trace viewer guide explains how to read one.