Authentication and storage state
Log in once in a setup project, save the browser state, and reuse it across the suite - plus how to test the login flow itself.
A setup project that saves state
// 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("heading", { name: "Dashboard" })).toBeVisible();
// persist cookies and localStorage for every later test
await page.context().storageState({ path: authFile });
});// playwright.config.ts
export default defineConfig({
projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "playwright/.auth/user.json",
},
dependencies: ["setup"],
},
],
});storageStatecaptures cookies and local storage in one file, which is enough for most token-based and session-cookie applications.- Add the auth file to
.gitignore. It is a valid session and therefore a credential. dependencies: ["setup"]makes the ordering explicit, so a stored state is never missing on a fresh checkout.- The setup project runs once per full run, not once per test, which is where the time saving comes from.
Several roles in one run
// tests/auth.setup.ts
import { test as setup } from "@playwright/test";
for (const role of ["admin", "member", "viewer"] as const) {
setup("authenticate as " + role, async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(role + "@example.com");
await page.getByLabel("Password").fill(process.env.E2E_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("**/dashboard");
await page.context().storageState({ path: "playwright/.auth/" + role + ".json" });
});
}// a fixture that gives a test a second, differently authenticated context
import { test as base } from "@playwright/test";
export const test = base.extend<{ adminPage: Page }>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: "playwright/.auth/admin.json",
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
// a test that needs two identities at once
test("a member cannot open the admin console", async ({ page, adminPage }) => {
await adminPage.goto("/admin");
await expect(adminPage.getByRole("heading", { name: "Admin" })).toBeVisible();
await page.goto("/admin");
await expect(page.getByText("Not authorised")).toBeVisible();
});💡
Storage state is captured per browser context, so a second role needs its own context rather than a second page. Two pages in one context share cookies, and the second login silently replaces the first - which is how 'multi-role' tests end up asserting the same user twice.
Still test the login flow
// the auth project is excluded from the stored-state fixture on purpose
test.describe("sign in", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("rejects a wrong password", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("wrong");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("alert")).toContainText("Incorrect email or password");
});
test("sends a reset link", async ({ page }) => {
await page.goto("/login");
await page.getByRole("link", { name: "Forgot password" }).click();
await page.getByLabel("Email").fill("[email protected]");
await page.getByRole("button", { name: "Send link" }).click();
await expect(page.getByRole("status")).toContainText("Check your email");
});
});- Override
storageStateto an empty state for tests that must start logged out, otherwise a reused session hides the very behaviour you are testing. - Keep a small number of login tests. They are the early warning that the mechanism changed, and they are cheap.
- Test the negative paths - wrong password, expired link, locked account - since those are the ones a UI change breaks silently.
- If the login form is behind a captcha in production, disable it in the test environment rather than trying to defeat it.
FAQ
Where should the storage state file live?
Anywhere outside version control. Put it under
playwright/.auth/, add that directory to .gitignore, and treat the file as a credential - it grants access until the session expires.Why does a test using storage state redirect me back to the login page?
The saved session has expired, or the application stores its token somewhere the state file does not cover, such as an in-memory store. Regenerate the state in the setup project, and confirm the application does not rely on session storage, which is not persisted.
Related
Runner, fixtures and CI API testing with request contexts
Last refreshed 2026-09-18.