# Authentication and Storage State

Course: [Playwright](https://www.devtutorials.ai/learn/playwright) · Section: Authentication, Network and APIs
Source: https://www.devtutorials.ai/learn/playwright/auth-network-api/authentication-and-storage-state

> Why logging in before every test is slow, what storage state holds (cookies and local storage), saving and reusing a login with storageState, the setup project pattern, logging in through the API, several user roles, logged-out tests, and keeping test accounts and session files safe.

## Authentication and Storage State

- Most real apps hide their best pages behind a login. Orders, accounts and admin pages all need a signed-in user.
- If every test fills in the login form first, the suite gets slow. It also breaks in many places when the login page changes.
- In this lesson, you will log in **once**, save the session, and reuse it in every test.

**Simple lesson:** log in once, save the session to a file, and start every test already logged in.

### A Real-World Example

- Asha checks her account on the Practice Shop. This test logs in through the form, then checks the welcome heading.
- It works. But imagine 200 tests that each start like this.

- Look at the time next to the test. Most of it is the login, not the check.

### What You Will Learn

1. Why repeated logins hurt, and what a session is.
2. What storage state holds, and how to save and load it.
3. The setup project pattern with `auth.setup.ts`.
4. Logging in through the API instead of the form.
5. Several user roles, and tests that need no login.
6. Keeping session files and passwords safe.

## Why Log In Once

### The Cost of Logging In Every Time

- A form login takes about a second in a real browser. Many apps take longer.
- 200 tests times 1 second is more than 3 minutes of typing the same password.
- Logins are also a common source of **flaky** tests. One slow login server fails many tests at once.
- The login form still needs tests. But only a few, in one file.

### What a Session Is

- When you log in, the app gives the browser a **session**. It is usually a **cookie**, or a token in **local storage**.
- On the next page, the browser sends the cookie back. The app knows who you are without a new login.
- If you copy that cookie and local storage into a fresh browser context, it is logged in too.

**Simple lesson:** a session is just stored data. Copy the data and you copy the login.

## Storage State

### What storageState Holds

- `context.storageState()` returns everything a context has stored: **cookies** and **local storage** for each origin.
- Playwright can save it to a JSON file, and load the file into a new context later.
- Session storage is not included. Most apps do not keep logins there.

<figure className="plw-fig">
<svg viewBox="0 0 640 220" role="img" aria-labelledby="fig-plw-state" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-plw-state">Animation: the setup context logs in, its cookies and local storage are saved to user.json, then three test contexts load the file and start logged in</title>
  <rect x="15" y="40" width="170" height="140" rx="12" style={{ fill: "var(--plw-purple-soft)", stroke: "var(--plw-purple)" }} />
  <text x="30" y="62" style={{ fill: "var(--plw-purple)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Setup context</text>
  <text x="30" y="88" style={{ fill: "var(--ink2)", fontFamily: "var(--mono)", fontSize: 11 }}>1. fill email</text>
  <text x="30" y="106" style={{ fill: "var(--ink2)", fontFamily: "var(--mono)", fontSize: 11 }}>2. fill password</text>
  <text x="30" y="124" style={{ fill: "var(--ink2)", fontFamily: "var(--mono)", fontSize: 11 }}>3. click Log in</text>
  <text x="30" y="155" opacity="0" style={{ fill: "var(--plw-orange)", fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700 }}>cookie sid=asha-…<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.15;0.2;0.95;1" dur="8s" repeatCount="indefinite" /></text>
  <g opacity="0">
    <animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.3;0.35;0.95;1" dur="8s" repeatCount="indefinite" />
    <line x1="190" y1="110" x2="255" y2="110" style={{ stroke: "var(--plw-orange)", strokeWidth: 3 }} markerEnd="url(#plw-arrow-state)" />
    <rect x="262" y="78" width="120" height="64" rx="8" style={{ fill: "var(--plw-orange-soft)", stroke: "var(--plw-orange)" }} />
    <text x="322" y="104" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, fontWeight: 700 }}>user.json</text>
    <text x="322" y="124" textAnchor="middle" style={{ fill: "var(--ink2)", fontFamily: "var(--sans)", fontSize: 11 }}>cookies + storage</text>
  </g>
  <defs><marker id="plw-arrow-state" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" style={{ fill: "var(--plw-orange)" }} /></marker></defs>
  <g style={{ fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>
    <g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.5;0.55;0.95;1" dur="8s" repeatCount="indefinite" /><line x1="385" y1="100" x2="440" y2="45" style={{ stroke: "var(--plw-green)", strokeWidth: 2 }} /><rect x="445" y="22" width="180" height="44" rx="8" style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }} /><text x="458" y="49" style={{ fill: "var(--plw-green)" }}>Test 1: logged in ✓</text></g>
    <g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.62;0.67;0.95;1" dur="8s" repeatCount="indefinite" /><line x1="385" y1="110" x2="440" y2="110" style={{ stroke: "var(--plw-green)", strokeWidth: 2 }} /><rect x="445" y="88" width="180" height="44" rx="8" style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }} /><text x="458" y="115" style={{ fill: "var(--plw-green)" }}>Test 2: logged in ✓</text></g>
    <g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.74;0.79;0.95;1" dur="8s" repeatCount="indefinite" /><line x1="385" y1="120" x2="440" y2="175" style={{ stroke: "var(--plw-green)", strokeWidth: 2 }} /><rect x="445" y="154" width="180" height="44" rx="8" style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }} /><text x="458" y="181" style={{ fill: "var(--plw-green)" }}>Test 3: logged in ✓</text></g>
  </g>
</svg>
<figcaption>One login fills the file. Every test context loads the file and skips the form.</figcaption>
</figure>

### Saving and Loading State

- Save: `await context.storageState({ path: 'playwright/.auth/user.json' })`.
- Load: `browser.newContext({ storageState: 'playwright/.auth/user.json' })`, or `test.use({ storageState: ... })`.
- This lab saves the state, prints the cookie names, then opens a **new** context from the file.

- The second page never saw the login form. The file made it logged in.

In the lab, files live only for one run. On your computer, the JSON file stays in the `playwright/.auth` folder until you delete it.

**Simple lesson:** `storageState({ path })` saves. The `storageState` option loads.

## The Setup Project Pattern

### auth.setup.ts and Project Dependencies

- This is the pattern the Playwright docs recommend for most apps.
- A **setup project** runs a file named like `auth.setup.ts`. It logs in and saves the file.
- Your normal project lists `dependencies: ['setup']` and loads the file with `storageState`.
- Playwright runs setup first. If setup fails, the other tests do not run, so you see one clear error.

- The output shows `[setup]` once, then `[chromium]` for each test.
- Neither test logs in. Both start logged in.

### Waiting for the Login to Finish

- Save the state only **after** the app has stored the session.
- `toHaveURL('/account')` waits for the redirect, which happens after the cookie is set.
- If you save too early, the file is empty and every test lands on the login page.

Never save storage state straight after clicking **Log in**. Wait for a sign that the login worked, such as the account URL or a "Welcome" heading.

**Simple lesson:** a setup project logs in once. `dependencies` makes the tests wait for it.

## Logging In Through the API

### Why the API Is Faster

- Many apps have a login endpoint, like `POST /api/login`.
- Calling it with the `request` fixture skips the page completely. No HTML, no typing.
- `request.storageState()` saves the cookies the server sent back, the same way a page does.
- **API Testing** covers `request` in full. Here you only need two calls.

- Check the time of the `[setup]` line. It is much faster than the form login.

### When the API Is Not Enough

- Some apps keep part of the login in **local storage**, set by page JavaScript. An API call cannot fill that.
- The Practice Shop's account page is one of them. It reads a `session` item from local storage.
- In that case, use the form login in setup, or add the local storage item yourself after the API call.

**Simple lesson:** use the API login when the app only needs cookies. Use the form when the page sets more.

## Several Users and Roles

### One File per Role

- Real apps have roles: customer, admin, support agent.
- Save one file per role: `admin.json`, `asha.json`. Then pick the file each test needs.
- `test.use({ storageState: ... })` inside a `describe` sets it for that group.

- The helper `logIn` keeps the setup file short. `Page` is the type of the `page` fixture. The JavaScript flavour drops it.

### Two Roles in One Test

- Some flows need two people at once. An admin changes something, and a customer sees it.
- Create one context per role from its file, as in **Browser Contexts, Pages, and Emulation**.

```ts
test('admin and customer together', async ({ browser }) => {
  const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const userContext = await browser.newContext({ storageState: 'playwright/.auth/asha.json' });
  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();
  // ... the admin acts, the customer checks
  await adminContext.close();
  await userContext.close();
});
```

### Tests That Must Be Logged Out

- The login page itself, and the "please log in" message, need a **logged-out** browser.
- Reset the state with an empty object: `{ cookies: [], origins: [] }`.

**Simple lesson:** one file per role. Pick it with `test.use`, or an empty state for logged-out tests.

## Keeping Sessions Safe

### Do Not Commit Session Files

- A storage state file is a **real login**. Anyone with the file can act as that user until the session ends.
- Add the folder to `.gitignore`:

```text
# .gitignore
playwright/.auth
```

### Passwords Come from the Environment

- Never write real passwords in test files. Read them from environment variables, as in **Playwright Configuration**.
- Locally, keep them in a `.env` file that is also in `.gitignore`. On CI, use the CI's secret store.

```ts
setup('log in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.SHOP_USER!);
  await page.getByLabel('Password').fill(process.env.SHOP_PASSWORD!);
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL('/account');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
```

### Test Accounts and Expiry

- Use **test accounts** made only for testing. Never use a real customer or your own admin account.
- Sessions expire. The setup project runs again on every `npx playwright test`, so the file is always fresh.
- If tests change account data, like the cart, give each **worker** its own account. The Playwright docs show a worker-scoped fixture for this, built like the fixtures in **Fixtures**.

Tests that change the same account at the same time can clash. If two tests empty one cart in parallel, one of them may fail. One account per worker fixes that.

**Simple lesson:** treat session files like passwords. Ignore them in git and use test accounts.

## Try It in Your Project

- These steps take the lesson from the lab to your own app.
- Add a setup project that logs in once and saves `playwright/.auth/user.json`.
- Add `playwright/.auth` to `.gitignore` and use a dedicated test account.
- Make one file run logged out with an empty storage state.

**Simple lesson:** use one idea from this lesson on your own app this week.

## TL;DR

- Logging in through the form in every test is slow and fragile.
- Storage state is cookies plus local storage. `storageState({ path })` saves it.
- A setup project logs in once. Other projects load the file with `storageState` and `dependencies`.
- API login is faster when the app only needs cookies.
- One file per role. `{ cookies: [], origins: [] }` for logged-out tests.
- Keep `playwright/.auth` and passwords out of git.

## Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| Saving state right after clicking Log in | The file has no session, tests land on the login page | Wait for the account URL or a welcome heading first |
| Forgetting `dependencies: ['setup']` | Tests run before the file exists and fail to read it | Add the dependency to every project that loads the file |
| Committing `playwright/.auth` | Anyone with the repo can use the login | Add the folder to `.gitignore` |
| Using an API login for an app that keeps the session in local storage | Pages still say "please log in" | Log in through the form, or add the local storage item |
| Many parallel tests changing one account | Tests break each other's data | One test account per worker |

## Check Yourself

## Exercises

## What to Remember

- Log in once, not in every test.
- `storageState({ path })` saves cookies and local storage. The `storageState` option loads them.
- Setup project plus `dependencies` is the standard pattern.
- API login is fastest, when cookies are enough.
- One file per role. An empty state for logged-out tests.
- Session files and passwords never go into git.

Next: **Network Interception and Mocking**
