If your Playwright tests feel slow because every one of them logs in from scratch, storage_state is the answer. By saving cookies and localStorage after login to a file and loading it in later tests, you can skip the login flow entirely. This article covers everything from the basics of saving and loading, to wiring it into pytest-playwright, managing multiple roles, and the practical pitfalls around expiry, parallel execution, and sensitive data.
storage_state isn’t a “login shortcut” — it’s a way of managing authentication state as a file. Understanding what gets saved, and what doesn’t, cuts down on mysterious authentication errors.
📌 Who This Article Is For
- Playwright (Python) users who log in at the start of every test and are watching their runtime grow
- Anyone who knows the name
storage_statebut is fuzzy on saving it to a file and the rules for using it - Anyone who wants to write tests for multiple roles, like admin and regular user
- Anyone whose authentication gets flaky in CI or under parallel execution
✅ What You’ll Learn
- How to save authentication state to a file and apply it to pytest-playwright’s default
page - What gets saved and what doesn’t (sessionStorage, for example)
- How to handle multiple roles, expiry, parallel execution, and sensitive data
👤 About the Author
Written by Yoshi, a QA and test automation engineer with over 15 years of hands-on experience, continuously refining how authenticated test infrastructure is designed and run with Playwright and pytest.
📌 Key Takeaways
- Save with
context.storage_state(path=...)and load withstorage_state=to skip the login flow - Cookies and localStorage are saved by default, and IndexedDB can be saved too when needed (
indexed_db=True). sessionStorage is not included - The saved file contains session data, so keeping it out of Git and being mindful of expiry are essential
When testing apps that require login, these problems tend to come up:
- Every test drives the login screen, adding a few seconds apiece
- A change to the login screen takes down unrelated tests all at once
- Tests for different roles, like admin and regular user, aren’t well organized
- Things got unstable once you turned on parallel execution
Designing your storage_state usage properly goes a long way toward easing all of them.
Where This Article Fits
Our advanced Page Object Model article introduces the idea of “create authentication state once in a session-scoped fixture and reuse it” as one part of POM design. This article pulls that piece out and goes deeper, focusing on the operational side: saving to a file, multiple roles, expiry, CI, and parallel execution. If you want to see how it fits together with POM, read the advanced article as well.
What Is storage_state, and What Gets Saved?
storage_state is a feature for saving the state of a browser context — cookies, localStorage, and so on — and carrying it over to another context. Reusing a logged-in state is its most common use.
| Item | Saved | Notes |
|---|---|---|
| Cookies | ✅ | Session cookies and the like |
| localStorage | ✅ | Used by SPAs that store things like a JWT |
| sessionStorage | ✕ | Not saved by storage_state. Apps that keep auth data here need a different approach |
| IndexedDB | ✅※ | ※ When you pass indexed_db=True (added in v1.51 — confirm against the docs for your version) |
① Saving Authentication State to a File
Here’s the basic form: log in, then write storage_state out to a file.
from playwright.sync_api import Browser
def save_auth_state(browser: Browser, base_url: str, path: str) -> None:
context = browser.new_context()
page = context.new_page()
page.goto(f"{base_url}/login")
page.get_by_label("Email").fill("test@example.com")
page.get_by_label("Password").fill("Password123")
page.get_by_role("button", name="Log in").click()
page.wait_for_url("**/dashboard") # confirm login finished before saving
context.storage_state(path=path) # write out as a JSON file
context.close()If your app keeps authentication data in IndexedDB (Firebase Authentication, for example), pass indexed_db=True when saving.
# Include IndexedDB in the saved state (v1.51 and later)
context.storage_state(path=path, indexed_db=True)storage_state before login finishes leaves you with a file that’s nearly empty of authentication data. Confirm the post-login state with wait_for_url() or expect() before saving.② Applying It to pytest-playwright’s Default page
With pytest-playwright, overriding the browser_context_args fixture applies the saved state to any test that uses the default context and page fixtures.
# conftest.py
import os
import pytest
AUTH_FILE = "auth/user.json"
@pytest.fixture(scope="session", autouse=True)
def prepare_auth(browser, base_url):
"""Log in once before the tests start, and save the auth state."""
os.makedirs("auth", exist_ok=True)
save_auth_state(browser, base_url, AUTH_FILE)
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""Apply the saved auth state to the default context."""
return {**browser_context_args, "storage_state": AUTH_FILE}# The test starts already logged in
def test_dashboard(page):
page.goto("/dashboard")
assert page.get_by_text("Welcome").is_visible()browser_context_args means even tests of the login screen itself, if they use the default page, start out logged in. For tests that need to see the pre-login state, create a separate context with browser.new_context(), or provide a dedicated unauthenticated fixture. Also note that if you call browser.new_context() yourself, the browser_context_args settings are not applied automatically — pass storage_state= explicitly, as in ③.③ Switching Between Multiple Roles
If you keep a separate file per role — admin and regular user, say — each test can pick the role it needs.
# conftest.py
import pytest
ROLE_FILES = {
"admin": "auth/admin.json",
"user": "auth/user.json",
}
@pytest.fixture
def page_as(browser):
"""A factory fixture that returns a page already logged in as the given role."""
contexts = []
def _page_as(role: str):
context = browser.new_context(storage_state=ROLE_FILES[role])
contexts.append(context)
return context.new_page()
yield _page_as
for context in contexts:
context.close() # don't forget the cleanupdef test_admin_can_see_user_list(page_as):
page = page_as("admin")
page.goto("/admin/users")
assert page.get_by_role("heading", name="User Management").is_visible()
def test_user_cannot_access_admin_page(page_as):
page = page_as("user")
page.goto("/admin/users")
assert page.get_by_text("You do not have permission").is_visible()page_as twice to get two independent contexts.④ Planning for Expiry
A saved file doesn’t last forever. Once the session expires, tests start failing with “mysterious authentication errors.”
import os
import time
MAX_AGE_SECONDS = 30 * 60 # example: only reuse files modified within 30 minutes
def is_auth_file_fresh(path: str) -> bool:
"""True if the file exists and was modified recently enough."""
if not os.path.exists(path):
return False
age = time.time() - os.path.getmtime(path)
return age < MAX_AGE_SECONDS
@pytest.fixture(scope="session", autouse=True)
def prepare_auth(browser, base_url):
if not is_auth_file_fresh(AUTH_FILE):
os.makedirs("auth", exist_ok=True)
save_auth_state(browser, base_url, AUTH_FILE)expires value or the server-side session lifetime. Find out the real expiry, and set MAX_AGE_SECONDS shorter than that to be safe.⑤ CI, Parallel Execution, and Sensitive Data
| Concern | What to do |
|---|---|
| Version control | The auth file contains session data, so add auth/ to .gitignore and never commit it. The official Playwright docs also use a playwright/.auth/ layout |
| CI | Generate it by logging in within the job. Keep credentials in Secrets, and don’t publish the auth file as an artifact |
| Parallel execution (pytest-xdist) | With pytest-xdist, session fixtures run once per worker, so a setup where every worker generates the same auth file at once risks write conflicts. Give each worker its own file name, or generate it once up front |
| Sharing one account | If one test logs out or changes the password, the server-side session can be invalidated, causing other tests using the same state to fail |
To split files per worker, use the worker_id fixture provided by pytest-xdist. The official Playwright authentication guide also describes splitting auth state files per worker for parallel runs (using parallelIndex on the Node side). Don’t just rename the file — make both the save step and the load step use the same auth_file.
# conftest.py (the fixed-AUTH_FILE version from ②, rewritten for parallel runs)
@pytest.fixture(scope="session")
def auth_file(worker_id):
# without xdist: user_master.json / with it: user_gw0.json, user_gw1.json ...
os.makedirs("auth", exist_ok=True)
return f"auth/user_{worker_id}.json"
@pytest.fixture(scope="session", autouse=True)
def prepare_auth(browser, base_url, auth_file):
save_auth_state(browser, base_url, auth_file) # the save path is now per-worker
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, auth_file):
return {**browser_context_args, "storage_state": auth_file} # same file when loadingCommon Mistakes Seen in Practice
① Committing the auth file to Git
auth.json contains session cookies and tokens. Putting it in the repository is a leak risk, so add it to .gitignore from the very beginning.
② Saving before login completes, then reusing an empty state
If you save too early, a file with no authentication data gets applied to every test, and all of them fail with auth errors. Always confirm the post-login state before saving.
③ Can’t reproduce login for an app that keeps auth in sessionStorage
Since sessionStorage isn’t saved, storage_state alone can’t restore the logged-in state for apps that keep authentication data there. You’ll need another approach (such as injecting the values with an init script), so check how your app handles authentication first.
④ Keeping an old file and missing that it expired
Reusing a stale auth.json left on your machine means tests suddenly start failing one day. Either build in a freshness check, or regenerate the file on every test run.
📖 Related Articles
- Advanced Page Object Model in Playwright — Pairing pytest Fixtures with Component Design for Better Maintainability
- Playwright + pytest Test Structure Best Practices
- Getting Started with Playwright | Browser Automation with Python — A Beginner’s Guide
- Getting Started with DevTools for Selenium & Playwright: How to Use the Elements, Console, and Network Panels
FAQ
Q. How many times can I reuse a saved auth file?
It’s governed by the app’s session lifetime, not a count. You can reuse it as many times as you like until the session expires. For apps with short sessions, regenerating it on every run is the safer approach.
Q. Can I log in through an API instead of the UI and save the state?
Yes. You obtain a token or cookie via the API, set it on a browser context, and then save storage_state. It’s faster since it skips UI interaction, but the steps depend on your app’s authentication method, so check the details for your case.
Q. Does storage_state work with APIRequestContext too?
Yes. Passing it like playwright.request.new_context(storage_state=...) sends API requests with the saved cookies attached.
Q. Is it okay to open and look at the auth file?
Looking is fine, but be careful not to include its contents in screen shares, logs, or screenshots. Cookie values and tokens are stored as-is, so treat it with the same care as a password.
Q. Does this make tests less reliable than logging in every time?
Not significantly, as long as you keep separate tests for the login flow itself. The basic design is to separate “tests of login” from “tests of features that assume you’re already logged in.”
📋 Summary
storage_statesaves and reuses cookies and localStorage, and can include IndexedDB when needed. sessionStorage is not included- With pytest-playwright, override
browser_context_argsto apply it to tests that use the defaultpage - Keep a separate file per role and switch between them with a factory fixture
- Three things hold the operation together: keep it out of Git, mind expiry, and watch out for parallel runs and shared accounts
Repeating the login flow quietly pushes up your suite’s runtime. Start by trying save-and-load with a single role, and once that works, expand to more roles and the operational rules above.
