All posts

Playwright storage state: save and reuse logins

Playwright storage state lets you save cookies and localStorage after one login and reuse them across tests. Learn setup, refresh, and CI-safe patterns now.

  • playwright
  • storage-state
  • authentication
  • cookies
  • localstorage
On this page

Playwright storage state is a JSON file of cookies and localStorage that browserContext.storageState() writes after a login, as the Playwright CLI storage docs describe. A setup project saves it once, and the storageState option loads it in your other projects, per Playwright’s authentication guide. sessionStorage isn’t saved, so re-create it in your own code.

Every test that fills in the same login form pays for it: extra seconds, a selector that breaks when the form changes, and a growing chance of tripping rate limits or an MFA prompt. Playwright storage state removes that repeat.

Per the official authentication docs, your sign-in routine saves authenticated state to a file in playwright/.auth, and tests load it, which “eliminates the need to authenticate in every test.” After reading, you can capture a login once, reuse it across tests, workers and roles, and keep it fresh and safe.

Key Takeaways

  • Treat the state file like a password: it carries live authentication tokens, so keep it out of git.
  • When tests change server-side state, a shared account lets them collide, so use per-worker accounts.
  • Stale state fails silently as a redirect to the login page, so check for a logged-in element first.
  • A hosted browser that keeps no profiles can still reuse a login if your code loads the state file.

What does Playwright storage state save?

Playwright storage state saves a browser context’s cookies and localStorage to a JSON file by default. Load the file with the storageState option to start a context already signed in.

Image generated

IndexedDB is included only when you pass indexedDB: true, an option added in Playwright 1.51. It stays off unless you request it.

sessionStorage is never saved. Playwright’s authentication guide says it “does not provide API to persist session storage”, so you restore it with an init script (shown below).

The path option on browserContext.storageState() writes the file. Without it, you only get the object back.

The JSON anatomy

{
  "cookies": [
    {"name": "session", "value": "abc123", "domain": "example.com", "path": "/", "expires": 1893456000, "httpOnly": true, "secure": true, "sameSite": "Lax"}
  ],
  "origins": [
    {"origin": "https://example.com", "localStorage": [{"name": "theme", "value": "dark"}]}
  ]
}

The cookies array holds one object per cookie. Each entry in origins has an origin and a list of localStorage name/value pairs. The CLI docs say the file holds “the cookies plus the localStorage of every origin the browser visited.”

A logged-out state is {"cookies":[],"origins":[]}. Point a test at it when that test must start signed out, even though your project default loads a user file.

Edit it by hand

Set a cookie’s expires to a past Unix timestamp to simulate an expired session. Delete an origins entry to drop that site’s localStorage token. Then run the test and check that your app redirects to login.

Restore sessionStorage yourself

import fs from "node:fs";
import { chromium } from "playwright";

const browser = await chromium.launch();
const context = await browser.newContext({ storageState: "playwright/.auth/user.json" });
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);
    }
  }
}, saved);

Create session.json earlier with page.evaluate(() => JSON.stringify(sessionStorage)).

How do you save and reuse Playwright storage state in a setup project?

You save and reuse Playwright storage state by logging in once in a setup project, writing the result to a JSON file, and pointing the storageState option of your test projects at that file.

Playwright’s authentication docs recommend creating a playwright/.auth directory and adding it to .gitignore. The steps below use that path.

  1. Capture the state in tests/auth.setup.ts. Wait for something that only exists after login before saving. If you save while the redirect is still running, the file holds a logged-out session.
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.APP_USER!);
  await page.getByLabel('Password').fill(process.env.APP_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('**/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await page.context().storageState({ path: authFile });
});
  1. Wire it up in playwright.config.ts. The chromium project depends on setup, so the login runs first and its file exists before any test starts.
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'http://localhost:3000' },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
  1. Write tests with no login code. Every test starts authenticated. To run one file logged out, override the option with an empty state.
import { test, expect } from '@playwright/test';

test('shows the account page', async ({ page }) => {
  await page.goto('/account');
  await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
});

test.describe('signed out', () => {
  test.use({ storageState: { cookies: [], origins: [] } });
  test('redirects to login', async ({ page }) => {
    await page.goto('/account');
    await expect(page).toHaveURL(/login/);
  });
});
  1. Check the result. Run npx playwright test. The setup project runs first, and playwright/.auth/user.json appears with cookies and origins fields.

Two alternatives

API login. If your app has a login endpoint, skip the browser. The request fixture writes the same file shape, as the “Authenticate with API request” section of the Playwright auth docs describes. The endpoint and form fields below are placeholders for yours.

setup('authenticate via API', async ({ request }) => {
  await request.post('/api/login', {
    form: { email: process.env.APP_USER!, password: process.env.APP_PASSWORD! },
  });
  await request.storageState({ path: 'playwright/.auth/user.json' });
});

Interactive capture. Log in by hand, then save the state from the CLI. The Playwright CLI storage page lists playwright-cli state-save auth.json to save to a named file and playwright-cli state-load auth.json to restore it. Then set storageState: 'auth.json' in your config.

To measure the savings on your own suite, run it once with per-test logins and once with this setup. Compare the durations in the HTML reporter (npx playwright show-report).

Shared account, per-worker or per-role: which storage state pattern fits?

Use a shared account when tests don’t change server-side state, one account per worker when they do, and one file per role when permissions differ. Each pattern prevents a specific failure.

PatternState filesWhat it avoidsWhen not to use
Shared accountOne fileRepeated loginsTests change server-side state, or authentication is browser-specific
One account per parallel workerOne file per worker index, from a worker-scoped fixtureTests colliding on shared server-side dataTests never touch shared data, so the extra accounts buy nothing
Per-role filesadmin.json, user.jsonRole mix-ups and admin rights leaking into user testsOnly one role is under test

The Playwright authentication docs list both limits of the shared account in their “Basic: shared account” section. The second pattern, “Moderate: one account per parallel worker”, is their answer to the first limit. For roles, see “Multiple signed in roles”.

Keep every role’s file in playwright/.auth. The setup file below saves one file per role, and the config makes the test project depend on setup:

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

export default defineConfig({
  use: { baseURL: process.env.BASE_URL },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const roles = [
  { file: 'playwright/.auth/user.json', email: process.env.USER_EMAIL!, password: process.env.USER_PASSWORD! },
  { file: 'playwright/.auth/admin.json', email: process.env.ADMIN_EMAIL!, password: process.env.ADMIN_PASSWORD! },
];

for (const role of roles) {
  setup(`sign in ${role.file}`, async ({ page }) => {
    await page.goto('/login');
    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).toHaveURL(/dashboard/);
    await page.context().storageState({ path: role.file });
  });
}

To test two roles in one test, the docs’ “Testing multiple roles together” section opens two browser contexts, each with its own storageState.

Override the default per file or per describe block:

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

test.describe('logged out', () => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test('redirects to login', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL(/login/);
  });
});

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

  test('opens the admin page', async ({ page }) => {
    await page.goto('/admin');
    await expect(page).toHaveURL(/admin/);
  });
});

When does Playwright storage state expire, and how do you refresh it?

Playwright storage state expires when the server ends the session it describes, not when the file gets old. The file is a snapshot of cookies and localStorage the server issued, and the Playwright CLI docs say these files “carry live authentication tokens”, so the server decides whether they still work.

Four mechanisms end a session:

  • A cookie’s expires or max-age value, stored in the file, passes.
  • The server’s own session timeout fires.
  • A logout or password change invalidates the token.
  • A rotating refresh token is spent, so the saved copy is dead.

Detect this before tests fail with a confusing timeout on a login form. In your setup project, request an authenticated page (an account page works) and check for a 200 instead of a redirect to login.

Three refresh strategies:

  1. Every run. The setup project logs in each time. It’s the simplest option and costs one login per run.
  2. TTL. Compare the file’s mtime with a max age you choose, and log in again when it’s older.
  3. Validate, then refresh. Probe, and log in only on failure.
import { chromium, request } from '@playwright/test';
import fs from 'node:fs';

const STATE = 'playwright/.auth/user.json';
const BASE = process.env.BASE_URL!;

async function isValid(): Promise<boolean> {
  if (!fs.existsSync(STATE)) return false;
  const api = await request.newContext({ baseURL: BASE, storageState: STATE });
  const res = await api.get('/account', { maxRedirects: 0 });
  await api.dispose();
  return res.status() === 200;
}

export async function ensureLogin(): Promise<void> {
  if (await isValid()) return;
  const browser = await chromium.launch();
  const page = await browser.newPage({ baseURL: BASE });
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER!);
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('**/dashboard');
  await page.context().storageState({ path: STATE });
  await browser.close();
}

To test the refresh path, edit a cookie’s expires in the JSON to a past Unix timestamp and run again.

How do you reuse storage state on a hosted CDP browser?

You reuse storage state on a hosted CDP browser by keeping the JSON file in your own code and loading it into a fresh context on every run.

Wayfern works this way. It keeps nothing between sessions, and our comparison with Multilogin states that Wayfern has “no customer-facing persistent profiles at all.” There are no named profiles, saved cookie jars or cookie import and export, so your code owns the state.

  1. Capture the state once. Log in with the setup project from the earlier section and save playwright/.auth/user.json. Use the same exit you’ll replay from (step 2).
  2. Connect with a sticky exit. Add country and session to the /ws URL so one residential exit IP serves the whole flow. The Wayfern browser API docs list the parameters.
  3. Load the file into a new context. Pass it to browser.newContext() after connectOverCDP().
  4. Open an authenticated URL. Success means the page loads and the URL isn’t your login route.
  5. Close the browser. Closing the last connection ends the session.
import { chromium } from "playwright";

const browser = await chromium.connectOverCDP(
  `wss://api.wayfern.com/ws?token=${process.env.LINK_TOKEN}&country=US&session=run42`,
);
const context = await browser.newContext({
  storageState: "playwright/.auth/user.json",
});
const page = await context.newPage();
await page.goto("https://example.com/account");
console.log(page.url());
await browser.close();

The exit matters. As our anti-detect browser sessions page puts it, “A cookie issued to one IP and replayed from another is an easy signal for a site to read.” Some sites bind a session to an IP or country, so capture and replay through the same country and session values.

The honest catch: the file doesn’t guarantee acceptance. The server decides, and a sticky IP doesn’t promise a site will accept the replay. If it redirects you to login, run the refresh logic from the previous section and save a new file.

Use this for your own accounts, testing and permitted data collection. Our guide to scraping behind logins covers the wider flow. Create a link token and run the script above against an account you own.

How do you keep storage state files safe in CI?

Playwright storage state stays safe in CI when you treat the file as a live password and follow six rules, each of which prevents a specific leak.

  1. Ignore the auth directory. Playwright’s authentication docs recommend you “add it to your.gitignore”. One line does it:
playwright/.auth

Prevents: tokens in git history. The Playwright CLI docs warn that these files “carry live authentication tokens”.

  1. Read credentials from CI secrets. Use process.env.TEST_USER and process.env.TEST_PASSWORD in your setup file. GitHub Actions documents secrets as a way to “store sensitive information in your organization, repository, or repository environments”. Prevents: passwords committed to the repo.

  2. Keep the file out of artifacts and shared caches. GitHub’s caching docs say, “We recommend that you don’t store any sensitive information in the cache.” A cached file can also be stale, and other jobs may read it. Prevents: exposed sessions and failures from expired state.

  3. Use a dedicated low-privilege test account. Rotate its password on a schedule. Prevents: a stolen session reaching real data.

  4. Regenerate state on every CI run. Let the setup project log in fresh, as in the refresh section above. Prevents: stale tokens and reliance on stored files.

  5. Treat a leaked file as a leaked session. Invalidate the account’s sessions on the server. Deleting the file doesn’t end the session, because the server still accepts the tokens. Prevents: continued access after the leak.

Why is my Playwright storage state not applied?

Playwright storage state usually fails to apply because the saved file is empty or points at the wrong origin, or because the config never loads it. Work down these checks, cheapest first. Open the JSON and read the cookies array before you blame Playwright.

  1. Saved before the login redirect finished. Symptom: the file exists but has no session cookie, and tests land on the login page. Cause: storageState() ran while the sign-in request was still in flight. Fix: wait for the post-login URL or an element first.
  2. Wrong origin or baseURL. Symptom: the file looks fine, yet the app acts logged out. Cause: the Playwright CLI docs say the file holds “the localStorage of every origin the browser visited,” so state captured on staging doesn’t match production. http versus https and www versus the apex domain also count as different hosts. Fix: compare the origins array and your baseURL.
  3. Config problem. Symptom: no project is logged in, or only some are. Cause: storageState is set on the wrong project, the setup project is missing from dependencies, or a leftover test.use overrides it. Fix: search the config for each.
  4. Role mix-up. Symptom: a test sees the wrong permissions. Cause: the wrong file in a describe block. Fix: set test.use({ storageState }) once per block, with the path for that role.

The first failure is easy to reproduce. If a setup test clicks Sign in and immediately saves, the file is written before the server sets its cookie:

await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });

Remove the waitForURL line and you may save an empty cookies array. The URL pattern is a placeholder for your app.

storageState gotchas checklist

  • The default file is cookies plus localStorage. The CLI docs describe it as “the full browser state (cookies + localStorage) in a single file,” so sessionStorage and IndexedDB need separate handling.
  • Don’t assume one state file works in every browser. If your login is browser-specific, use one file per browser project.
  • Never commit the file. Follow the CI rules in the previous section.

Frequently asked questions

How do I reuse a login in Playwright?

Sign in once in a setup project, call page.context().storageState({ path }) to write a JSON file, then set storageState to that path in the use block of the projects that depend on setup. Per Playwright’s authentication docs, each test then starts with the saved cookies and localStorage and skips the login.

Does storageState save sessionStorage?

Playwright’s storageState leaves sessionStorage out, because Playwright has no API to persist it. If your app keeps a token there, the authentication docs show a snippet to save and restore it by hand.

Does storageState include IndexedDB?

Only when you pass indexedDB: true to storageState(), an option added in Playwright 1.51. Without it, the file holds cookies and localStorage, as the CLI storage docs describe.

Should I commit auth.json?

Don’t commit it. The file carries live authentication tokens, so add its directory to .gitignore. Playwright’s docs recommend playwright/.auth for this.

Next steps: add storage state to your suite this week

Add Playwright storage state in three passes rather than one rewrite.

  • Today: Move one login into a setup project, write its output to playwright/.auth, and add that directory to .gitignore.
  • This week: Add a stale-state check that fails fast when the saved session stops working. Split into per-role files only where permissions differ.
  • This month: Decide how CI and hosted browsers get state and refresh it. Keep credentials in secrets.

When you’re ready to run authenticated flows on a hosted browser, the Developer plan or a credit pack is the cheapest self-serve start. There’s no free tier, so check Wayfern pricing first, then follow the quickstart in our docs.

Sources

Storage & Authentication | Playwright · Authentication | Playwright · BrowserContext | Playwright · Release notes | Playwright · Wayfern vs Multilogin · Anti-Detect Browser Sessions over CDP | Wayfern · Using secrets in GitHub Actions - GitHub Docs · Dependency caching reference - GitHub Docs

See us more often in Google

One click marks Wayfern as a preferred source in Google, so our posts can show up more often in your Top Stories, AI Mode and AI Overviews.