How to make injected storage_state cookies stick in a browser agent
I burned about four hours on a browser-use agent that kept landing on a login screen even though I’d handed it a storage_state.json full of valid cookies. No exception, no error in the logs.
Chromium had the cookies sitting in its jar. The app just never sent them back to the server, or the server didn’t accept them, and the agent had no way of knowing the difference. That’s the specific trap with injected storage_state: Playwright doesn’t fail loudly when a cookie doesn’t attach to a request. context.cookies() will show you the cookie is there. The page will still redirect you to /login. The break is almost always in the gap between how the cookie was captured and how the agent creates its context or navigates afterward, not in the cookie value itself.
This is for anyone running Playwright directly, or through a wrapper like browser-use, Stagehand, or a custom LangGraph browser tool, trying to skip a login step on accounts they own or are authorized to automate. By the end you’ll have a capture-and-inject pattern that actually works, and a short list of the two failure modes, context creation order and domain/origin scoping, that account for most of these silent failures. If you filed something like browser-use issue #2799, where storage_state looked correct in the file but the account still came up logged out, this is that bug.
what you need
- Playwright installed (
pip install playwright && playwright install chromium) or a wrapper like browser-use that drives Playwright underneath - an account you’re authorized to automate, logged in once manually so you can capture real cookies rather than guessing at them
- Python or Node depending on your stack, examples below are Python since that’s what browser-use runs on
- a plain text editor that won’t mangle JSON quoting, VS Code or Notepad++ are both fine
- about 15 minutes and a throwaway test run, since you’ll trigger a few re-auths while you get this right
- this only works within the target site’s terms of service, and only on sessions you’re authorized to hold, it’s not a workaround for accounts that aren’t yours
No cost beyond compute for this part.
Cost only shows up later if you’re running this at a scale where you need consistent network identity, more on that in “scaling this.”
step by step
1. capture a clean storage_state from a real login
Run a short script that opens a real browser window, log in by hand, then export the state. This mirrors what Playwright’s own auth guide calls “reuse signed-in state.”
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("https://app.example.com/login")
input("log in manually, then press enter")
context.storage_state(path="state.json")
browser.close()
Expected output: a state.json file with a “cookies” array and an “origins” array containing localStorage entries.
If it breaks: if origins is empty but you know the app uses localStorage for auth (most SPAs do, for a JWT or refresh token), you captured the state before the app finished writing to storage. Add a short wait, or wait for a specific post-login element, before calling storage_state().
2. read the JSON before you trust it
Open state.json and check three things: the domain field on the session cookie, whether secure and httpOnly are set the way you’d expect, and the expires value (Unix epoch seconds, or -1 for a session-only cookie that Chromium won’t persist across a fresh context).
{
"cookies": [
{
"name": "session_id",
"value": "a1b2c3d4",
"domain": "app.example.com",
"path": "/",
"expires": 1790000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
]
}
Expected output: values that match what you saw in devtools’ Application tab during the manual login.
If it breaks: an expires value in milliseconds instead of seconds (13 digits instead of 10) reads as a date decades away or already passed, and Chromium will silently drop or misjudge the cookie. This happens most often when someone hand-builds the JSON from a script that pulled Date.now() instead of the epoch-seconds format Playwright expects.
3. inject storage_state at context creation, never after
This is the single most common mistake. storage_state has to be passed when you create the BrowserContext, before any page navigates.
browser = p.chromium.launch(headless=True)
context = browser.new_context(storage_state="state.json")
page = context.new_page()
page.goto("https://app.example.com/dashboard")
Not this:
context = browser.new_context()
page = context.new_page()
page.goto("https://app.example.com") # unauthenticated request already fired
context.add_cookies(json.load(open("state.json"))["cookies"]) # too late
Expected output: the dashboard loads directly, no login redirect.
If it breaks: check you’re not reusing an old context object that already navigated once before you called add_cookies. The Playwright BrowserContext API documents storage_state as a constructor option for exactly this reason, it has to exist before the first request leaves the browser.
4. if you’re using a persistent context, check whether storage_state is even being read
launch_persistent_context() loads its own cookie jar from the user_data_dir profile on disk. Depending on your installed Playwright version, passing storage_state alongside a persistent context either gets ignored, or gets applied and then partly overwritten once the profile’s own cookie store loads.
context = p.chromium.launch_persistent_context(
user_data_dir="./profile-persona-1",
storage_state="state.json",
headless=True,
)
Expected output: your installed version’s docs confirm whether storage_state is honored by launch_persistent_context in that release, this has changed across versions.
If it breaks: stop fighting the persistent profile. Either seed the user_data_dir’s cookie database directly before launch, or drop the persistent context and use a plain launch() plus new_context(storage_state=...) per persona instead, that’s the version of this that behaves predictably.
5. match the cookie’s domain to the domain you actually navigate to
A cookie captured with domain app.example.com and no leading dot is host-only, per MDN’s Set-Cookie reference: no explicit Domain attribute means an exact host match is required. That cookie will not attach to a request against example.com or secure.example.com, even though a human would call those “the same site.”
Expected output: the domain field in state.json matches, character for character, the host your page.goto() call targets, or is a proper parent domain if the original Set-Cookie header specified one.
If it breaks: this is common when you captured the state on a staging environment (.staging.example.com) and are injecting it against production, or when a login flow redirects through an auth subdomain that differs from the app subdomain. Recapture the state from the exact host your agent will navigate to.
6. match the origins block to the exact origin, scheme included
localStorage is scoped to scheme, host, and port together, not domain. https://app.example.com and http://app.example.com are different origins as far as localStorage is concerned, and so are app.example.com and www.app.example.com.
Expected output: the “origin” string inside state.json’s origins array matches what page.url() reports once your agent has navigated, exactly.
If it breaks: if your agent ends up on https://www.example.com after a redirect but you captured storage_state from https://example.com, the localStorage entries never get attached. Recapture from wherever the redirect actually lands, not from wherever you typed the URL.
7. verify the session actually authenticated, don’t trust context.cookies()
page.goto("https://app.example.com/dashboard")
if page.locator("[data-testid='account-menu']").count() == 0:
raise RuntimeError("storage_state did not authenticate the session")
Expected output: an exception raised immediately if the injected state didn’t work, instead of the agent silently proceeding against a logged-out page.
If it breaks: if the page hangs instead of showing you either the dashboard or a login screen, that’s usually a separate navigation problem, see how to fix Playwright’s page.goto() timing out with no load event ever firing.
8. plan for the state going stale
Some session cookies are marked expires: -1, meaning session-only, and some sites invalidate a session server-side the moment they see it from a different IP or device fingerprint than the one that created it, regardless of what the cookie itself says.
Expected output: a storage_state file that authenticates today might not authenticate in three days, even with a valid, unexpired cookie.
If it breaks: build a cheap heartbeat, navigate to a logged-in-only page on a schedule and recapture storage_state if it fails, rather than assuming a capture from last week is still good.
common pitfalls
- capturing storage_state on staging and injecting it against production, or the reverse, because the cookie domain and the target domain look similar but aren’t identical
- hand-editing the JSON and writing the expires field in milliseconds instead of the epoch seconds Playwright expects
- setting
sameSite: "None"withoutsecure: true, Chromium drops these cookies outright rather than sending them - reusing one storage_state file across dozens of concurrent contexts, which some sites treat as a session hijack signal and invalidate for everyone using it, not just the newest one
- treating
context.cookies()returning your cookie as proof of login, when it only proves the cookie is sitting in the local jar, not that the server accepted it on the last request
scaling this
At 10 personas, this is a manual, boring process. One state.json per account, recaptured by hand every few days, verified with the step 7 check before each run.
At 100 personas, manual recapture stops being realistic. You want a scheduled heartbeat per persona that checks auth and recaptures storage_state automatically when it fails, plus enough isolation between personas that one broken JSON file doesn’t take the whole batch down when a run hits it.
At 1000, the cookie stops being the main variable. Most risk engines on banking, social, and marketplace platforms weight IP and device consistency alongside the session token itself, so a technically valid storage_state can still get challenged or silently downgraded to a logged-out state if the request comes from an IP that doesn’t match the one the session was built on. See how IP reputation is earned and lost for why that scoring exists in the first place. If your personas are already anchored to Singapore IPs, something like Singapore Mobile Proxy gives each one a sticky session on a real SG mobile IP, so the network identity doesn’t drift out from under a session that’s already authenticated. It’s Singapore-only, so it’s only relevant if that’s actually where your targets or personas are.
where to go next
If you’re driving your own Chrome profile over CDP instead of a clean Playwright context, that’s a related but separate failure mode, covered in how to fix Chrome 136 refusing to be driven over CDP with your default profile. If you’re building this out across a fleet of agents rather than one persona, Chrome for Testing vs open source Chromium for a Playwright agent fleet covers the binary choice that affects how consistently your contexts behave. The rest of the how-to library is at /blog/.
Written by Xavier Fok
disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-23.