QA How-To
How to Use Playwright storageState reuse (2026)
Use playwright storageState reuse securely with setup projects, API login, role files, IndexedDB capture, expiry handling, and isolated parallel test accounts.
20 min read | 2,524 words
TL;DR
Authenticate in a Playwright setup project, save page.context().storageState({ path }), and configure dependent test projects with use.storageState. Keep the file out of source control, wait until login is complete before saving, and use separate accounts or worker-scoped state when tests mutate shared server data.
Key Takeaways
- Create authentication state in a setup project and declare explicit dependencies from browser projects.
- Store state under playwright/.auth or a cleaned output directory, and never commit reusable session material.
- Wait for the final authenticated URL or UI before saving cookies and origin storage.
- Use one shared state only when concurrent tests cannot interfere through the same server-side account.
- Save IndexedDB explicitly when the application stores authentication there, and handle sessionStorage separately.
- Treat expiry, role boundaries, cleanup, and CI secret handling as part of the authentication test design.
Playwright storageState reuse lets a new isolated browser context start with cookies and origin storage captured from an earlier authenticated context. The maintainable pattern is to sign in once in a setup project, save the state to a disposable file, and configure dependent test projects to load it. Most feature tests then begin authenticated without repeating the login UI.
Reuse is not just a performance trick. The state file is sensitive session material, and a shared account can still create backend collisions even though Playwright gives every test a fresh browser context. This guide covers the secure baseline, UI and API authentication, IndexedDB, expiry, multiple roles, unauthenticated overrides, sessionStorage, parallel workers, and CI operation for 2026.
TL;DR
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('button', { name: 'Account menu' }))
.toBeVisible();
await page.context().storageState({ path: authFile });
});
| Scenario | State strategy | Isolation level |
|---|---|---|
| Read-only tests | One shared state file | Fresh context, shared backend account |
| Role coverage | One file per role | Fresh contexts and separate role accounts |
| Mutating parallel tests | One file per worker | Separate client and backend identities |
| Unauthenticated test | Empty state override | No cookies or origins |
| IndexedDB auth | Save with indexedDB: true | Captures required database state |
| sessionStorage auth | Explicit capture and init script | Custom origin-scoped restoration |
1. Understand playwright storageState reuse
Every Playwright Test receives isolated browser contexts by default. Isolation means cookies and storage from one test do not leak naturally into another. storageState provides an intentional seed for a new context. It serializes cookie data and origin storage, including localStorage. When requested during saving, it can also include IndexedDB.
The file is a snapshot, not a live shared browser profile. Loading it does not make tests share one page or one BrowserContext. Each test still receives a clean context initialized from the same values. This distinction preserves client isolation while avoiding repetitive authentication.
Server-side state is different. Two contexts authenticated as the same user see the same cart, profile, tenant records, and permissions on the server. If tests mutate those resources concurrently, a shared state file can make failures nondeterministic.
storageState is also not a universal credential vault. It does not persist sessionStorage automatically, browser extensions, passkeys, operating-system credentials, or arbitrary in-memory application state. Its contents expire according to the application's cookies and tokens.
Use it to establish a known browser identity, not to skip all authentication testing. Keep focused login, logout, lockout, MFA, and expiry tests. Most feature coverage can start after login, while a smaller authentication suite proves the sign-in journey itself.
2. Create and protect the authentication directory
Create a dedicated directory and exclude it from Git:
mkdir -p playwright/.auth
printf '\nplaywright/.auth\n' >> .gitignore
Do not commit even a state file from a nonproduction account. Cookies and tokens may allow impersonation until they expire or are revoked. A private repository is not a safe exception. Developers, build systems, backups, forks, and artifacts broaden exposure.
Use least-privilege accounts created for automation. The account should have only the role needed for the tests and no access to real customer data. Prefer short-lived sessions in CI and revoke credentials when a secret may have leaked.
For state that should exist only during one run, save under testInfo.project.outputDir or another cleaned output path. This avoids stale local sessions and makes cleanup automatic. A stable playwright/.auth path is convenient for local development, but it needs a refresh procedure and secure file permissions.
Add secret-scanning rules where available, but do not rely on scanners to identify every cookie format. Avoid attaching the raw JSON to a report, printing it during debugging, or storing it in a broad CI cache. If the file moves between jobs, use an encrypted artifact with minimal retention and limited access.
The state path is part of test configuration, while email and password belong in environment secrets. Never place credentials directly in the spec, config, or generated JSON metadata.
3. Implement the recommended setup project
A setup project is a real Playwright project whose tests prepare prerequisites. Consumer projects declare a dependency, so authentication completes before browser tests begin.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
The setup project can use normal locators, assertions, fixtures, traces, and reports. That integration is why it is generally preferable to an opaque globalSetup function for authentication. A failing login appears as a named test rather than a generic startup exception.
The dependency runs for each Playwright invocation. In a sharded CI matrix, that usually means each shard creates state in its own private workspace. This is simple and avoids transferring a sensitive file. If login rate limits make per-shard setup expensive, consider API authentication or a secure provisioning job, but keep ownership and expiry explicit.
UI mode does not automatically run setup projects in the same way as a full command for speed. Developers should know how to enable and run the setup project when local state expires.
4. Save state only after login is complete
Authentication often sets cookies through several redirects. Saving immediately after clicking Sign in can capture an intermediate state. Wait for a final URL and an authenticated UI contract:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await Promise.all([
page.waitForURL(/\/dashboard$/),
page.getByRole('button', { name: 'Sign in' }).click(),
]);
await expect(page.getByRole('button', { name: 'Account menu' }))
.toBeVisible();
await page.context().storageState({ path: authFile });
});
Promise.all is appropriate because the URL wait is registered before the click within the same expression. The authenticated control adds a second signal that the app has initialized. Adapt both to the product rather than copying dashboard and Account menu literally.
If login uses an external identity provider, wait through its return redirect to your application origin. Avoid asserting secrets or token values. A trace can help diagnose the redirect chain, but protect it because authentication traces may include sensitive request data.
After saving, a small setup assertion can create a new context from the file and request a protected page, but do not duplicate the entire suite. The first dependent smoke test should also fail clearly if state is invalid.
For robust locator and wait choices, review Playwright auto-waiting and assertions.
5. Use API login when it represents the same session
If the application exposes a supported login endpoint that sets the same authentication cookies, APIRequestContext can create state faster and with fewer UI dependencies:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate through API', async ({ request }) => {
const response = await request.post('/api/test-login', {
data: {
email: process.env.E2E_EMAIL,
password: process.env.E2E_PASSWORD,
},
});
expect(response.ok()).toBeTruthy();
await request.storageState({ path: authFile });
});
The endpoint in this example is application-specific. Do not invent a production bypass. Use a real supported authentication contract or a controlled test-support endpoint with the same authorization review as other privileged interfaces.
API login is appropriate for feature tests that need an identity but are not validating the login page. Keep dedicated UI login tests so form validation, redirects, accessibility, and identity-provider integration remain covered.
Request context cookies can seed browser contexts through the same storageState file. Confirm that the application does not require client-generated localStorage or IndexedDB values that the API flow never creates. A successful login response is not enough if the browser still redirects to login.
Use Playwright APIRequestContext patterns for provisioning and cleanup as well. APIs often create prerequisite data more reliably than long UI setup sequences.
6. Configure playwright storageState reuse for IndexedDB
Some authentication libraries store session material in IndexedDB. Current Playwright can include IndexedDB when taking the browser context snapshot by setting indexedDB: true:
await page.context().storageState({
path: 'playwright/.auth/user.json',
indexedDB: true,
});
Use this option based on evidence. Open the application storage view during a manual authenticated session or consult the application team. If cookies and localStorage are sufficient, capturing more state increases file scope without benefit.
After saving, close the original context normally. A dependent test creates a fresh context from the file:
import { test, expect } from '@playwright/test';
test('starts with IndexedDB-backed authentication', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' }))
.toBeVisible();
});
Do not parse and edit IndexedDB entries in the JSON unless a specific low-level test requires it. The format represents browser storage, and manual token mutation creates an unrealistic session. Regenerate state through the authentication contract instead.
Current BrowserContext also provides setStorageState for applying a state to an existing context on supported Playwright versions. The config and browser.newContext({ storageState }) patterns remain clearer for normal Playwright Test setup because the context begins in the intended state before pages navigate.
7. Handle multiple roles without cross-contamination
Admin, editor, and viewer tests should not share one overprivileged identity. Generate one state file per role with separate accounts:
// tests/roles.setup.ts
import { test as setup, expect } from '@playwright/test';
const roles = [
{ name: 'admin', emailKey: 'E2E_ADMIN_EMAIL' },
{ name: 'viewer', emailKey: 'E2E_VIEWER_EMAIL' },
] as const;
for (const role of roles) {
setup(`authenticate ${role.name}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env[role.emailKey]!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('current-role'))
.toHaveText(role.name);
await page.context().storageState({
path: `playwright/.auth/${role.name}.json`,
});
});
}
Projects can map each role to the appropriate file, or fixtures can create contexts explicitly for a test involving two actors. Use role-specific projects when most tests need one role. Use custom fixtures when one scenario needs admin and viewer simultaneously.
Avoid one account whose role is changed during setup. Parallel jobs can observe the wrong permissions, and authorization defects may be masked because the account is too powerful. Least-privilege identities make expected access explicit.
State filenames should communicate role, environment, and possibly browser when sessions are browser-specific. Do not use the same local file for QA and staging if cookies have overlapping domains or different trust boundaries.
8. Reset authentication for public and logout tests
A project-level storageState applies to every test in that project unless overridden. Public-page and signed-out tests can request an empty state:
// tests/public.spec.ts
import { test, expect } from '@playwright/test';
test.use({ storageState: { cookies: [], origins: [] } });
test('redirects an anonymous user from account settings', async ({ page }) => {
await page.goto('/settings');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: 'Sign in' }))
.toBeVisible();
});
Place the override at file scope when every test in the file is anonymous. A separate unauthenticated project is better when many files share that policy.
Logout tests should start authenticated, perform the real logout action, and verify that a protected route is no longer accessible. Do not save the logged-out context over the shared state file. Tests run in fresh contexts, so one test's logout should not mutate the source file or other tests' client state.
Also distinguish an expired session from no session. Expiry tests may need a short-lived token issued by a test API or controlled clock support in the application. Hand-editing expiry fields in the state snapshot can validate client behavior but may not reproduce server-side revocation.
9. Deal with sessionStorage and state expiry
storageState does not persist sessionStorage. If the application truly uses it for authentication, capture and restore it explicitly:
// Capture after authentication.
const state = await page.evaluate(() =>
JSON.stringify(window.sessionStorage)
);
// Restore before application scripts execute in a new context.
await context.addInitScript(storage => {
if (window.location.hostname === 'app.example.test') {
for (const [name, value] of Object.entries(storage)) {
window.sessionStorage.setItem(name, String(value));
}
}
}, JSON.parse(state));
In production code, save the captured object to a protected disposable location if it must cross processes. Restrict restoration to the exact expected hostname. A broad init script can leak state to an unrelated origin visited by the test.
Expiry needs a policy. CI can create fresh state every run, which is the simplest choice. Local workflows may reuse a file for speed, but file existence does not prove session validity. Provide a command that reruns the setup project and a clear failure message when the protected landing page redirects to login.
Do not build a complex token decoder just to predict expiry unless your application owns that contract. Server revocation, rotation, and cookie policy can invalidate apparently fresh state. A quick authenticated health check is stronger than trusting a timestamp alone.
When authentication becomes invalid during a long run, do not silently relogin inside every failed feature test. That hides expiry defects and creates unpredictable setup. Fail with a clear authentication diagnosis and regenerate state at the intended boundary.
10. Scale reuse safely across CI shards and workers
A shared account is recommended only when all tests can run simultaneously without affecting one another. Read-only tests often meet that condition. Tests that edit preferences, create records with nonunique names, consume inventory, or exercise one active session per user do not.
For mutating tests, authenticate once per worker and save under its output directory. testInfo.parallelIndex is suitable for selecting a unique account:
import { test as base, request } from '@playwright/test';
import path from 'node:path';
export const test = base.extend<{}, { workerState: string }>({
storageState: ({ workerState }, use) => use(workerState),
workerState: [async ({}, use) => {
const id = base.info().parallelIndex;
const file = path.join(
base.info().project.outputDir,
'.auth',
`${id}.json`
);
const account = await acquireAccount(id);
const api = await request.newContext({
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
storageState: undefined,
});
await login(api, account);
await api.storageState({ path: file });
await api.dispose();
await use(file);
await releaseAccount(account);
}, { scope: 'worker' }],
});
acquireAccount, login, and releaseAccount are application functions, not Playwright APIs. They should coordinate across overlapping CI runs. The fixture uses a clean request context so it cannot inherit a project-level user.
For distributed execution, review Playwright sharding across machines examples. Shards and workers multiply login attempts, account demand, and target load. Size the account pool for the maximum concurrent runs, not one pipeline.
Authentication setup also needs observable ownership. Record which setup project created a state file, the nonsecret account alias, the environment, and the run identifier without logging tokens. A small manifest may contain paths and timestamps, but it must never duplicate cookies, passwords, or tokens. When a feature test is redirected unexpectedly, this metadata helps distinguish an expired session from a wrong environment or an exhausted account pool. Keep setup failures separate from product assertion failures in dashboards, because a broken identity provider can invalidate every shard without indicating dozens of independent feature regressions.
Interview Questions and Answers
Q: What problem does storageState solve?
It separates authentication setup from feature tests. A setup flow captures cookies and origin storage, and each later test receives a fresh context seeded from that snapshot. Tests stay isolated on the client while avoiding repeated login UI.
Q: Why prefer a setup project over globalSetup?
A setup project uses the normal test runner, so authentication has assertions, fixtures, reports, retries, and traces. Dependencies make ordering visible in config. globalSetup can work, but failures and artifacts are less naturally integrated.
Q: What is stored and what is missing?
Cookies and origin storage such as localStorage are captured. IndexedDB is included when requested with indexedDB: true. sessionStorage, passkeys, and arbitrary JavaScript memory are not automatically represented.
Q: Is one storageState file safe for parallel tests?
It is safe only if the same backend account can support those operations concurrently. Each browser context is separate, but the server identity is shared. Mutating tests normally need worker-specific accounts or stronger data isolation.
Q: How do you keep the state file secure?
I exclude it from Git, use least-privilege test accounts, keep CI retention short, limit artifact access, and never log its content. I prefer regenerating it inside each job instead of moving it between trust boundaries.
Q: When should API authentication replace UI authentication?
For feature suites, when a supported API establishes the same browser session faster and more reliably. I retain targeted UI login tests for the actual sign-in experience. I verify that the resulting state can open a protected browser page.
Q: How do you support an unauthenticated test in an authenticated project?
I override storageState with empty cookies and origins at the file or test scope. A separate anonymous project is useful when many tests require that state. I then assert the protected route's redirect and sign-in UI.
Common Mistakes
- Committing playwright/.auth because the account is supposedly only a test account.
- Saving state immediately after clicking Sign in, before redirect cookies and app initialization finish.
- Assuming a fresh BrowserContext also creates a fresh backend account.
- Reusing one admin state for viewer tests and weakening authorization coverage.
- Checking only whether the state file exists, not whether the session remains valid.
- Expecting sessionStorage to appear in the normal storageState snapshot.
- Forgetting indexedDB: true when the application's authentication library requires IndexedDB.
- Sharing one writable state path across concurrent jobs.
- Printing state JSON or attaching it to a broadly accessible report.
- Relogging inside arbitrary feature tests and hiding authentication expiry failures.
Conclusion
Reliable Playwright storageState reuse begins with a setup project, a protected disposable file, and a clear readiness check before saving. Configure dependent projects to load the snapshot, use API login when it creates the same session, and capture IndexedDB only when the application needs it. Handle sessionStorage explicitly.
Next, classify the suite by backend interference. Keep one shared state for genuinely safe read-only tests, create separate role files for authorization coverage, and move mutating parallel tests to worker-specific accounts. That decision provides both faster execution and credible isolation.
Interview Questions and Answers
Why use storageState in Playwright tests?
It lets fresh isolated browser contexts begin with reusable authenticated cookies and origin storage. Tests avoid repeating login UI while retaining context isolation. This improves speed and keeps login coverage separate from most feature tests.
What is the recommended setup-project pattern?
Create an auth.setup.ts test that signs in and saves state. Add a setup project matching that file, then make browser projects depend on setup and set use.storageState to the generated path. Playwright runs the dependency before its consumers.
What security risk does a storageState file create?
It can contain cookies or tokens that allow account impersonation. I keep it out of source control, minimize artifact exposure and retention, restrict account privileges, and generate disposable state in CI. Logs and traces must not print its contents.
When is one shared authenticated account unsafe?
It is unsafe when parallel tests change server-side data such as carts, settings, permissions, or records visible to the same user. Browser contexts isolate client state but not backend state. In that case I allocate accounts per worker or test.
How does IndexedDB affect storageState reuse?
Some applications store authentication material in IndexedDB. When saving state, pass indexedDB: true so that data is captured. Verify the application's storage model because enabling it unnecessarily increases state scope.
How do you know when it is safe to save state after login?
I wait for the final post-login URL and a stable authenticated UI element or response. Cookies can arrive during redirects, so saving immediately after clicking Sign in can capture incomplete state. The assertion becomes the readiness contract.
How would you support admin and user roles?
I create separate state files with separate least-privilege accounts. Projects or custom fixtures select the correct file, and cross-role tests can create two contexts explicitly. I never mutate one file to switch roles during parallel execution.
Frequently Asked Questions
What does Playwright storageState contain?
It contains browser-context cookies and origin storage such as localStorage. IndexedDB can be included when saving with indexedDB: true. It does not automatically persist sessionStorage.
Where should I save Playwright authentication state?
A common location is playwright/.auth, with that directory added to .gitignore. If state should live only for one run, put it under a project output directory that Playwright cleans.
Should I use globalSetup or a setup project for storageState?
A setup project is generally preferred because it participates in Playwright reporting, traces, fixtures, and project dependencies. globalSetup can work, but it has fewer test-runner integration benefits.
Can several Playwright projects reuse the same storageState file?
Yes, if the state is valid for those browsers and the account can safely handle concurrent tests. If authentication is browser-specific or tests modify shared account state, use separate files or accounts.
How do I refresh an expired Playwright storageState file?
Run the authentication setup again and overwrite the disposable state file. In CI, the setup project can create fresh state every run; for local reuse, provide a documented command and avoid guessing validity from file existence alone.
Does storageState save sessionStorage?
No. sessionStorage is scoped differently and is not part of the normal saved state. If an application truly authenticates through sessionStorage, capture it explicitly and restore it with addInitScript for the relevant origin.
Can I create Playwright storageState through an API login?
Yes. Use APIRequestContext to call a supported login endpoint, then save request.storageState({ path }). This is faster when the API establishes the same cookies or origin state required by the browser.