Authentication and Storage State

9 min read

If your test suite logs in via the UI for every test, you're paying a 5-second tax 500 times — almost an hour of wall time burned on the same login flow. Storage state is Playwright's built-in answer: log in once per test session, save the resulting cookies and localStorage to a JSON file, and have every subsequent test load that file at context-creation time. Tests start logged in, the login flow itself is tested separately (and only once), and the suite gets dramatically faster. This lesson covers the Python pattern, the multi-role variant, and the API-backed auth shortcut that's even faster than UI-driven login.

What storage state actually is

A BrowserContext's "state" is everything that survives a page reload but not a context close: cookies, localStorage, sessionStorage, and IndexedDB origins. storage_state(path=...) serialises all of it to a JSON file:

{
  "cookies": [
    {"name": "session", "value": "abc123", "domain": "myapp.com", ...}
  ],
  "origins": [
    {
      "origin": "https://myapp.com",
      "localStorage": [{"name": "user_id", "value": "42"}]
    }
  ]
}

The reverse — browser.new_context(storage_state="auth.json") — opens a context already loaded with those cookies and storage. The page that opens in that context is, from the server's perspective, a logged-in user.

The session-scoped login fixture

The pattern: log in once in a session-scoped fixture, save the state, share the path with every test:

import pytest
from pathlib import Path
from playwright.sync_api import Browser
 
 
@pytest.fixture(scope="session")
def admin_storage_state(browser: Browser, base_url: str):
    context = browser.new_context(base_url=base_url)
    page = context.new_page()
 
    page.goto("/login")
    page.get_by_label("Email").fill("admin@test.com")
    page.get_by_label("Password").fill("AdminPass")
    page.get_by_role("button", name="Login").click()
    page.wait_for_url("/dashboard")
 
    state_path = Path("tests/.auth/admin.json")
    state_path.parent.mkdir(parents=True, exist_ok=True)
    context.storage_state(path=str(state_path))
    context.close()
 
    return str(state_path)

Once per pytest run, the fixture launches a fresh context, drives the UI through a login, saves the state, and closes the context. Subsequent tests reuse the saved file via:

@pytest.fixture
def admin_page(browser: Browser, admin_storage_state: str, base_url: str):
    context = browser.new_context(
        storage_state=admin_storage_state,
        base_url=base_url,
    )
    page = context.new_page()
    yield page
    context.close()

Each test takes admin_page instead of page, and the page opens already authenticated:

def test_admin_dashboard(admin_page):
    admin_page.goto("/admin")
    expect(admin_page.get_by_role("heading")).to_have_text("Admin Dashboard")

No login flow in the test body. The dashboard test runs in milliseconds, not seconds.

Multiple roles — one fixture per persona

Real apps have admin, standard user, viewer, billing, etc. Build one storage-state fixture per role and one page fixture per role:

@pytest.fixture(scope="session")
def admin_storage_state(browser: Browser, base_url: str):
    return _login_and_save(browser, base_url, "admin@test.com", "AdminPass", "admin.json")
 
 
@pytest.fixture(scope="session")
def user_storage_state(browser: Browser, base_url: str):
    return _login_and_save(browser, base_url, "user@test.com", "UserPass", "user.json")
 
 
@pytest.fixture(scope="session")
def viewer_storage_state(browser: Browser, base_url: str):
    return _login_and_save(browser, base_url, "viewer@test.com", "ViewerPass", "viewer.json")
 
 
def _login_and_save(browser, base_url, email, password, filename):
    context = browser.new_context(base_url=base_url)
    page = context.new_page()
    page.goto("/login")
    page.get_by_label("Email").fill(email)
    page.get_by_label("Password").fill(password)
    page.get_by_role("button", name="Login").click()
    page.wait_for_url("/dashboard")
    path = Path(f"tests/.auth/{filename}")
    path.parent.mkdir(parents=True, exist_ok=True)
    context.storage_state(path=str(path))
    context.close()
    return str(path)

One helper, three role-specific fixtures, three saved JSON files. Each test takes whichever role it needs as a parameter — admin_page, user_page, viewer_page.

API-backed auth — even faster

UI login through getByLabel and click is faster than re-doing it per test, but it's still slower than skipping the UI entirely. If your app exposes an /api/login endpoint, hit it directly:

from playwright.sync_api import Playwright
 
 
@pytest.fixture(scope="session")
def admin_storage_state(playwright: Playwright, base_url: str):
    request = playwright.request.new_context(base_url=base_url)
    response = request.post("/api/login", json={
        "email": "admin@test.com",
        "password": "AdminPass",
    })
    assert response.ok
    token = response.json()["token"]
 
    # Open a context, set the auth artefact, save state
    browser = playwright.chromium.launch()
    context = browser.new_context(base_url=base_url)
    context.add_cookies([{
        "name": "session_token",
        "value": token,
        "domain": "myapp.com",
        "path": "/",
    }])
    # Or set localStorage if your auth uses it:
    page = context.new_page()
    page.goto(base_url)
    page.evaluate(f"localStorage.setItem('auth_token', '{token}')")
 
    path = "tests/.auth/admin.json"
    context.storage_state(path=path)
    context.close()
    browser.close()
    request.dispose()
    return path

Two HTTP round-trips and a quick context spin-up — typically 200-500ms vs 3-5s for UI login. The trade-off is you're not exercising the login UI; pair this with one dedicated test that does drive the login through the UI to keep that flow covered.

Don't commit the state files

Storage-state files contain real session cookies — never commit them. Add to .gitignore:

tests/.auth/

The fixture regenerates the state at the start of every CI run, so there's nothing to commit anyway. If a CI run leaks state into the artefacts, that's a security issue worth fixing — treat the .auth/ directory like you'd treat .env files.

How the storage-state pattern flows

Step 1 of 5

1. First test requests admin_page

Pytest sees admin_page depends on admin_storage_state. The session fixture hasn't run yet — pytest builds it now.

The win compounds: 100 tests that needed 5 seconds of UI login each (500s total) now spend ~5 seconds total on login plus a few hundred milliseconds per test loading the saved state. On a 1000-test suite the savings are minutes.

A complete production conftest

Putting the patterns from this lesson and conftest patterns from chapter 3 together:

# tests/conftest.py
import pytest
from pathlib import Path
from playwright.sync_api import Browser
 
AUTH_DIR = Path("tests/.auth")
AUTH_DIR.mkdir(parents=True, exist_ok=True)
 
 
def _login(browser, base_url, email, password, role):
    context = browser.new_context(base_url=base_url)
    page = context.new_page()
    page.goto("/login")
    page.get_by_label("Email").fill(email)
    page.get_by_label("Password").fill(password)
    page.get_by_role("button", name="Login").click()
    page.wait_for_url("/dashboard")
    path = AUTH_DIR / f"{role}.json"
    context.storage_state(path=str(path))
    context.close()
    return str(path)
 
 
@pytest.fixture(scope="session")
def admin_storage_state(browser, base_url):
    return _login(browser, base_url, "admin@test.com", "AdminPass", "admin")
 
 
@pytest.fixture(scope="session")
def user_storage_state(browser, base_url):
    return _login(browser, base_url, "user@test.com", "UserPass", "user")
 
 
@pytest.fixture
def admin_page(browser, admin_storage_state, base_url):
    context = browser.new_context(storage_state=admin_storage_state, base_url=base_url)
    page = context.new_page()
    yield page
    context.close()
 
 
@pytest.fixture
def user_page(browser, user_storage_state, base_url):
    context = browser.new_context(storage_state=user_storage_state, base_url=base_url)
    page = context.new_page()
    yield page
    context.close()

Two roles, ~50 lines of conftest, and every test in the suite can opt into admin_page or user_page and start logged in.

Coming from Playwright TypeScript?

The TS course handles this with playwright.config.ts's globalSetup plus projects with dependencies: ['setup']:

// TS pattern
{
  name: 'setup',
  testMatch: /.*\.setup\.ts/,
},
{
  name: 'admin',
  use: { storageState: 'admin.json' },
  dependencies: ['setup'],
}

Python's session-scoped fixture is the more direct equivalent — you write Python code, not config — and arguably easier to reason about: pytest's dependency graph builds the storage-state fixture before the page fixture that uses it, no dependencies: ['setup'] declaration needed.

⚠️ Common mistakes

  • Committing storage-state files to git. The JSON contains real session cookies. If your project gets cloned and the cookie hasn't expired, anyone with the repo can impersonate the test user. Add tests/.auth/ to .gitignore before the first commit; if it's already in history, rotate the credentials and clean the history.
  • Putting the storage-state fixture at function scope. The point is to log in once. A @pytest.fixture (default function scope) on the login flow undoes the win — every test logs in again. Always declare login fixtures scope="session".
  • Forgetting that storage state is environment-specific. A state file generated against staging won't work against production — the cookie domains differ. Either gate the auth fixture on base_url (use a different filename per environment) or regenerate per CI run. Don't try to share state across environments.

🎯 Practice task

Implement the storage-state pattern. 30-40 minutes.

  1. In tests/conftest.py, add a session-scoped fixture that logs in via Sauce Demo and saves the state:

    from pathlib import Path
    import pytest
     
    AUTH_DIR = Path("tests/.auth")
    AUTH_DIR.mkdir(parents=True, exist_ok=True)
     
    @pytest.fixture(scope="session")
    def standard_user_state(browser):
        context = browser.new_context(base_url="https://www.saucedemo.com")
        page = context.new_page()
        page.goto("/")
        page.get_by_placeholder("Username").fill("standard_user")
        page.get_by_placeholder("Password").fill("secret_sauce")
        page.get_by_role("button", name="Login").click()
        page.wait_for_url("https://www.saucedemo.com/inventory.html")
     
        path = AUTH_DIR / "standard_user.json"
        context.storage_state(path=str(path))
        context.close()
        return str(path)
     
    @pytest.fixture
    def authed_page(browser, standard_user_state):
        context = browser.new_context(
            storage_state=standard_user_state,
            base_url="https://www.saucedemo.com",
        )
        page = context.new_page()
        yield page
        context.close()
  2. Add tests/.auth/ to your .gitignore.

  3. Write three tests that all use authed_page:

    from playwright.sync_api import expect
     
    def test_inventory_loads_logged_in(authed_page):
        authed_page.goto("/inventory.html")
        expect(authed_page.locator(".inventory_item")).to_have_count(6)
     
    def test_can_add_to_cart(authed_page):
        authed_page.goto("/inventory.html")
        authed_page.locator(".inventory_item").first \
            .get_by_role("button", name="Add to cart").click()
        expect(authed_page.locator(".shopping_cart_badge")).to_have_text("1")
     
    def test_can_view_cart(authed_page):
        authed_page.goto("/inventory.html")
        authed_page.locator(".shopping_cart_link").click()
        expect(authed_page).to_have_url("https://www.saucedemo.com/cart.html")
  4. Run with pytest -v --durations=10. Note that the storage_state fixture runs once at the start; the three tests start logged in immediately. Compare to the same suite without storage state — typically a 2-3x speedup on the test phase.

  5. Add a second role. Sauce Demo has problem_user (broken images) and performance_glitch_user (slow). Add a problem_user_state fixture and a problem_user_page fixture. Write one test that uses each. Run the suite — both roles log in once, every test starts authenticated.

  6. Stretch: convert one of the storage-state fixtures to use the API-backed approach. Sauce Demo doesn't have an API, but write the equivalent fixture against your dev environment if you have one. Time the difference. The API version typically runs in 200-500ms vs 3-5s for the UI version.

You have the auth pattern that turns slow UI suites into fast ones. The next lesson covers the corner cases real apps throw at you — popups, iframes, and JavaScript dialogs.

// tip to track lessons you complete and pick up where you left off across devices.