Home / Blog / Playwright authentication

Playwright authentication: logging in once, and what breaks after that

Quick answer

Do the login once in a setup project, save the browser state with storageState, and point your test projects at it. That covers most suites. It stops covering yours the moment tests change server-side state a sibling can see, the moment two roles are involved, or the moment the session expires mid-run — and each has a different answer.

The tutorial navigated to a public URL and asserted on the title. The application you are paid to test puts a login form in front of every screen, and often an identity provider you do not own, a second factor, a session that dies while the suite is still running, and a security reviewer asking why there is a file full of cookies in the repository. Everything below assumes the suite already works and the login is the thing standing in front of it.

How do you log in once and reuse it in every test?

Put the login in a project that runs before the others, write the browser state to a file, and hand that file to the test projects. Playwright calls the first part a setup project, and the test projects read the file through the storageState option.

Every sample here was run against Playwright 1.62, on a small application built for the job: a sign-in form, a session cookie, a protected route, a second variant that keeps its token in localStorage, and an admin who can approve what a user submits. The config used channel: 'chrome'.

import { test as setup, expect } from '@playwright/test';

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

setup('sign in as the standard user', async ({ page }) => {
  await page.goto('/sign-in');
  await page.getByLabel('Work email').fill(process.env.USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('heading')).toHaveText(`Signed in as ${process.env.USER_EMAIL}`);

  await page.context().storageState({ path: userFile });
});
Playwright 1.62 · TypeScript · tests/auth.setup.ts · passes

The assertion before storageState is there to make the file trustworthy. It waits for something only a signed-in browser reaches, so the state is captured after the cookies have landed. The credentials come out of the environment, as they do in every sample on this page.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:4310',
    channel: 'chrome',
  },
  projects: [
    { name: 'setup', testMatch: '**/*.setup.ts' },
    {
      name: 'chrome',
      dependencies: ['setup'],
      use: { storageState: 'playwright/.auth/user.json' },
    },
  ],
});
Playwright 1.62 · TypeScript · playwright.config.ts

testMatch decides which files the setup project runs, and dependencies: ['setup'] puts it in front of the rest. The documented behaviour is that the dependency runs first, the projects that depend on it start once all its tests have passed, and if a dependency fails the tests relying on it do not run at all. A login that breaks therefore stops the run, and four hundred specs never open a browser to be redirected to the sign-in page.

Keep this in a setup project rather than in globalSetup. Playwright's comparison of the two gives project dependencies the recommendation, and it is specific about what the config option gives up: no entry in the HTML report, no trace recording, no fixtures. The login is the most fragile step in the suite, and inside globalSetup it fails with nothing to open.

When does one shared account stop working?

One question decides it. Does a test change server-side state that another test can see? A suite where every test reads can share one account across every worker. A suite that archives an order, spends a credit, marks a notification read or renames a shared record cannot, and it announces itself as a spec that fails only when a sibling happened to run first — on the day somebody ran it on a machine with more cores.

PatternUse it whenWhat it costsWhat breaks it
One shared account, one state file Every test reads, and nothing a test writes is visible to another test One sign-in per run The first spec that writes. It passes alone and fails once a sibling has written first
One account per parallel worker Tests change server-side state A pool of test accounts, and somebody who owns creating them Running more workers than the pool has accounts
One state file per role The product behaves differently for an admin, a viewer or a billing owner A sign-in per role in the setup project A spec that forgets to select a role and quietly runs as whichever one the config names
Authenticate through the API The login is scaffolding rather than the thing under test Nothing in the suite drives the form your users use Anything the form sets that the endpoint does not: a form token, a device record, a first-run redirect

The second row is a worker-scoped fixture. It signs in one account when the worker starts and gives every test that worker runs the same state file. test.info().parallelIndex is the key: it runs from zero to one below the worker count, which maps it onto a fixed pool of accounts.

import { test as base, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';

export const test = base.extend<{}, { workerStorageState: string }>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [async ({ browser }, use) => {
    const id = test.info().parallelIndex;
    const file = path.resolve(test.info().project.outputDir, `.auth/worker-${id}.json`);
    if (fs.existsSync(file)) {
      await use(file);
      return;
    }

    const page = await browser.newPage({
      storageState: undefined,
      baseURL: test.info().project.use.baseURL,
    });
    await page.goto('/sign-in');
    await page.getByLabel('Work email').fill(`worker${id}@example.com`);
    await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByRole('heading')).toHaveText(`Signed in as worker${id}@example.com`);

    await page.context().storageState({ path: file });
    await page.close();
    await use(file);
  }, { scope: 'worker' }],
});

export { expect } from '@playwright/test';
Playwright 1.62 · TypeScript · playwright/fixtures.ts · passes

Run with four workers and eight specs, each worker signed in once and the four accounts never met each other. The file goes under test.info().project.outputDir, which is cleaned before every run, so a worker's session is minted when that worker starts and never inherited from a run that finished yesterday. How a fixture is declared, scoped and torn down is a subject with its own page; this one is about what goes inside it.

Choose between the two rows deliberately, because authentication is one of the few decisions in a suite that is cheap to make on day one and expensive to change at four hundred specs — every spec inherits it. That puts it beside the other structural choices a framework build settles early.

How do you run tests as more than one role?

One role per file. Save a state file per role in the setup project. A spec then declares which role it runs as, either at the head of the file or inside a describe block: test.use({ storageState: 'playwright/.auth/viewer.json' }). Whatever does not declare one inherits the role the config names, which is the third row of the table above going wrong.

Two roles in one test. When the scenario is one person submitting and another approving, a single page cannot hold it. Two contexts in the same test can.

import { test, expect } from '@playwright/test';

test('an admin approves the order a user has just submitted', async ({ browser, baseURL }) => {
  const userContext = await browser.newContext({ baseURL, storageState: 'playwright/.auth/user.json' });
  const adminContext = await browser.newContext({ baseURL, storageState: 'playwright/.auth/admin.json' });
  const userPage = await userContext.newPage();
  const adminPage = await adminContext.newPage();

  await userPage.goto('/orders');
  await expect(userPage.getByRole('listitem').first()).toContainText('submitted');

  await adminPage.goto('/orders');
  await adminPage.getByRole('button', { name: 'Approve' }).first().click();
  await expect(adminPage.getByText('Order approved')).toBeVisible();

  await userPage.reload();
  await expect(userPage.getByRole('listitem').first()).toContainText('approved');

  await userContext.close();
  await adminContext.close();
});
Playwright 1.62 · TypeScript · passes

The user's page is reloaded after the admin has acted, so the last assertion holds only if both sessions are live and looking at the same record. Two pages inside one context would share a cookie jar, and both would be whoever signed in last.

Two roles behind fixtures. Once three specs open two contexts by hand, the plumbing has earned a name: an adminPage and a userPage fixture, whose bodies are the four context-and-page lines above, moved. The spec then reads as the scenario, and the context plumbing lives in one file.

Can you skip the login form, and when must you not?

Yes, and a suite of any size usually should. APIRequestContext can post to the login endpoint and then write the same kind of state file the browser route writes.

import { test as setup, expect } from '@playwright/test';

const userFile = 'playwright/.auth/api-user.json';

setup('sign in through the API', async ({ request }) => {
  const response = await request.post('/api/session', {
    data: {
      email: process.env.USER_EMAIL,
      password: process.env.USER_PASSWORD,
    },
  });
  expect(response.status()).toBe(200);

  await request.storageState({ path: userFile });
});
Playwright 1.62 · TypeScript · tests/api-auth.setup.ts · passes

request.storageState() returns the cookies the request context is currently holding and writes them to path. A local storage snapshot comes with them only if one was handed to the context when it was built, so a sign-in that never opened a browser writes an origins array that is empty: if your application keeps its token in localStorage, this file does not carry it. It is quicker than driving the form and it does not break the morning somebody redesigns the sign-in page. There is a second limit: a session minted by an endpoint is not always the same object as one minted by the form, which is the subject of the third failure below.

The same change has to carry a correction. If every test takes the API route, nothing in the suite drives the login your users drive — usually the most-used screen in the product, and the one most likely to have quietly lost its coverage on the day the suite got fast. So one spec opts out and does it properly.

import { test, expect, type Page } from '@playwright/test';

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

async function submitSignIn(page: Page, password: string) {
  await page.goto('/sign-in');
  await page.getByLabel('Work email').fill(process.env.USER_EMAIL!);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();
}

test('a correct password signs the user in', async ({ page }) => {
  await submitSignIn(page, process.env.USER_PASSWORD!);
  await expect(page.getByRole('heading')).toHaveText(`Signed in as ${process.env.USER_EMAIL}`);
});

test('a wrong password is refused and says so', async ({ page }) => {
  await submitSignIn(page, 'not-the-password');
  await expect(page.getByRole('alert')).toHaveText('That email and password do not match an account.');
});

test('signing out puts the form back', async ({ page }) => {
  await submitSignIn(page, process.env.USER_PASSWORD!);
  await page.getByRole('link', { name: 'Sign out' }).click();
  await expect(page.getByRole('heading')).toHaveText('Sign in');
});
Playwright 1.62 · TypeScript · passes

test.use({ storageState: { cookies: [], origins: [] } }) throws away the state the project handed this file, so these three tests start as a stranger would. One valid sign-in, one rejected password, one sign-out is enough; this is not the place to test the login exhaustively.

What about SSO, MFA and passkeys?

Passkeys have a supported answer. A browser context carries a virtual WebAuthn authenticator. Install it and the page's navigator.credentials.create() and navigator.credentials.get() ceremonies are answered without a hardware key. Better still for a suite, storageState({ credentials: true }) captures the passkey the page registered, and restoring that state installs the authenticator again on its own, so a passkey login becomes something a whole suite can reuse.

import { test, expect } from '@playwright/test';

const passkeyFile = 'playwright/.auth/passkey.json';

test('a passkey is enrolled once and reused from a saved state', async ({ browser, baseURL }) => {
  const enrolment = await browser.newContext({ baseURL });
  await enrolment.credentials.install();
  const enrolmentPage = await enrolment.newPage();
  await enrolmentPage.goto('/passkey');
  await enrolmentPage.getByRole('button', { name: 'Create a passkey' }).click();
  await expect(enrolmentPage.getByText('Passkey registered')).toBeVisible();
  await enrolment.storageState({ credentials: true, path: passkeyFile });
  await enrolment.close();

  const returning = await browser.newContext({ baseURL, storageState: passkeyFile });
  const page = await returning.newPage();
  await page.goto('/passkey');
  await page.getByRole('button', { name: 'Sign in with a passkey' }).click();

  await expect(page.getByRole('heading')).toHaveText(`Signed in as ${process.env.USER_EMAIL}`);
  await returning.close();
});
Playwright 1.62 · TypeScript · passes

The second context never calls install(). Restoring a state file that contains credentials does it, as documented. Note what that file now holds: the captured credential carries its private key, which makes it a heavier secret than a jar of cookies.

A time-based code has a workable answer with a cost. If the test account's second factor is a rotating six-digit code and your team holds the shared secret, a test can derive the current code the same way the authenticator app does and type it into the form. The cost is that the shared secret is now a secret your test infrastructure holds, under the same rules as everything else in the last section.

An SMS code, a push approval, or an identity provider your company does not control is not a Playwright problem. No amount of test code gets through a factor that requires a human or a second device. What the team needs is a test account exempted from the second factor, or a non-production tenant that issues sessions without one, and both are a conversation with whoever owns identity rather than a technique. State the trade when you ask for it: the suite is then testing the application with that factor removed, so the factor itself is covered some other way or it is not covered, and somebody should decide which on purpose.

Faking the identity provider's response looks like the way round all of this, and it costs the thing the suite was for: a run that fabricates its own session never exercises the path that issues real ones. Where that line falls for the rest of your requests is argued elsewhere.

Where a saved session breaks after it has been working

Four ways, and they have different symptoms.

The session expires mid-run. The suite signs in at minute zero; at minute forty the token minted then is dead, and the failures land on whichever specs happened to run last. The same commit produces a different set of red tests every time, which is how this gets filed as flakiness. Run against a four-second session on purpose, the report gave nothing away:

Error: expect(locator).toHaveText(expected) failed

Locator:  getByRole('heading')
Expected: "Signed in as ada@example.com"
Received: "Sign in"
Timeout:  5000ms
Playwright 1.62 · the run that failed

Nothing in that message says the word session. The redirect to the sign-in page never appears; an assertion times out against the wrong heading, so it reads like a timeout worth investigating and it is not one. The same run's API read was blunter and came back 401. Repairs, in the order to prefer them: authenticate per worker so the state is minted when the worker starts rather than when the run does; ask for a longer-lived token in the test environment; write the state under the project's output directory so it can never be reused from an earlier run. Playwright's documentation puts the last two together: "Note that you need to delete the stored state when it expires. If you don't need to keep the state between test runs, write the browser state under testProject.outputDir, which is automatically cleaned up before every test run."

A session tied to something the CI machine does not have. Bound to an IP, a device fingerprint, a user agent, or the browser the state was captured in and is now being replayed into. The state file is valid and the server refuses it anyway. Reproduced by replaying a saved state into a context with a different user agent, the request carried the cookie and came back 401 — a different failure from the cookie never being sent, and a trace shows which of the two you have.

A form token frozen into a saved state. The login page issued a token, the state file captured it, and it is single-use or bound to a session that has since rotated. Reads pass and the first write fails, usually with a 403 that has nothing to do with permissions, which is why this one takes the longest to diagnose: the symptom points at authorisation and the cause is a stale form token. Replayed twice against the local application, the first write succeeded, the server rotated the token, and the second replay read the orders page fine and got 403 on the approve. Mint the token in the test instead of replaying it, and check whether the API route from the section above is skipping the step that issues it.

The account exists on your laptop and not in CI. Green locally against a database with three months of history behind it, red in a pipeline that starts from a migration: the account, the tenant, the role assignment or the feature flag is a fixture on one machine and absent on the other. The session is only ever as real as the account behind it, and that account is test data with an owner of its own.

Only the first of the four moves around. The other three fail the same way every run once you know to look, so establish that before you go through the usual causes of a flaky test.

Where do the credentials live?

Not in the repository. A saved state file can sign in as the account it was captured from, and Playwright says so in a danger box: "The browser state file may contain sensitive cookies and headers that could be used to impersonate you or your test account. We strongly discourage checking them into private or public repositories." With credentials: true it also contains a private key. Put playwright/.auth in .gitignore on the day you create the directory, and write state you do not need to keep under the project's output directory, which is emptied before every run.

Passwords, tokens and any shared secret come from the environment. In CI they come from the pipeline's secret store and reach the job as environment variables; they are not committed, not baked into an image, and not printed by a step that logs its own configuration. No sample on this page contains a credential: a reader copies the block, not the paragraph beside it.

The review question that catches the drift: if this repository were public tomorrow, what would be in it?

Getting a suite past a login is not a security assessment of your authentication — session fixation, token leakage and brute-force protection are a different discipline, and not one Firm86 sells. And what you are reusing is a browser session, mobile browsers included; Playwright drives browsers, so a native mobile application is not something any of this reaches.

Questions

How do I stay logged in between Playwright tests?

Put the login in a setup project and save the browser state to a file with page.context().storageState({ path }) and then point your test projects at that file through the storageState option, giving them dependencies: ['setup'] so the login runs first. Every test in those projects then opens already signed in, and the suite signs in once per run instead of once per test.

Can Playwright get through SSO or MFA?

Partly, and the three cases are different. Passkeys have a supported answer: the browser context carries a virtual WebAuthn authenticator that answers the ceremony, and the passkey can be saved into the storage state and reused. A time-based code is workable if your team holds the shared secret, at the cost of holding that secret in test infrastructure. An SMS code, a push approval or an identity provider your company does not control is not a Playwright problem at all — what is needed there is a test account exempted from the second factor, or a non-production tenant that issues sessions without one, and both come from whoever owns identity.

Should I commit the storage state file?

No. The file can be used to impersonate the account it was captured from, and Playwright's own documentation discourages checking it into repositories, private or public. Add playwright/.auth to .gitignore before you write the first state file, and for state you do not need between runs, write it under the project's output directory, which is cleaned before every run.

Why does my Playwright login pass locally and fail in CI?

Four candidates, in the order they are worth checking. The account, tenant or feature flag exists in your local database and not in the pipeline's. The credential is missing from the CI secret store, so the setup project signs in as an empty string. The session is bound to something the runner does not have, such as an IP or a user agent, in which case the request carries the cookie and still comes back 401. Or the state file is stale, and the failures land on whichever specs ran last.

Do I still need a test that logs in through the form?

Yes, one. If the whole suite authenticates through the API, nothing left in it drives the screen your users see most. Keep a single spec that resets the inherited state with test.use({ storageState: { cookies: [], origins: [] } }) and covers a valid sign-in, a rejected password and a sign-out.

Which of the four is your suite on, and was it chosen?

Most suites are on the first row because that is what the first spec needed, and nobody has looked since. Send the shape of it: how many specs, how many workers the pipeline runs, whether any of them write something another test reads, and what your login puts in front of a browser. An engineer here can tell you from that whether the change is a config edit or a job that touches every spec file. If you would rather start from what you already have, the audit is optional and no engagement here waits on one.