← all articles

How to persist browser sessions and cookies for AI agents

An agent that logs in from scratch on every run will eventually hit an MFA prompt at 3am with nobody awake to answer it. Even when that never happens, every login adds a page load, a possible verification email and a possible lockout, and you trigger it hundreds of times instead of once.

This is for teams running Playwright agents against their own accounts, or accounts whose owners have agreed to it. The examples are Python because that’s what my agent code is in, and the JavaScript API is a near copy. It does not cover getting past CAPTCHAs or bans. If a site throws a challenge, the answer is a human. And if the site offers an API with OAuth tokens, use that instead of a browser session, because it will outlast any cookie.

By the end you’ll have a save, load, verify and refresh loop, encrypted session storage and an expiry check that fails loudly. One limit: I’ve only worked this through on Chromium. I haven’t tried Firefox or WebKit profiles, so the profile section is Chromium only.

What you need

  • Playwright for Python (free, Apache 2.0), with Chromium installed via playwright install chromium
  • the cryptography package for Fernet encryption (free)
  • an account you own or have consent to use, and a human who can complete the first login, MFA included
  • durable storage for state: a mounted volume, an encrypted S3-compatible bucket or a Postgres column. A state file baked into a container image is gone at the next deploy
  • somewhere for the encryption key. An environment variable is fine to start
  • optional: a fixed exit IP (step 7)

Apart from that optional exit IP, nothing here costs more than the compute and storage you already run.

Step by step

Step 1: decide what has to persist

Log in by hand in a headed browser, open DevTools, go to the Application tab and find where the login lives: cookies, local storage, session storage or IndexedDB.

In the cookies table, read the Expires column. A cookie with no Expires or Max-Age is a session cookie, and the MDN Set-Cookie reference says the browser decides when the session ends and may delete it then. Chromium also caps cookie lifetime at 400 days (as of September 2026), whatever the server asks for.

Expected output: one line per site, like “auth = cookie sid (HttpOnly) plus localStorage key token”. That note decides which steps you need.

If it breaks: if nothing looks like a token, the site ties the session to something server-side. Go to step 5.

Step 2: set up encrypted state storage

A session file is a credential. Playwright’s own docs warn that these files can be used to impersonate the account and discourage committing them to any repository. So encrypt at rest, write atomically and add state/ and profiles/ to .gitignore. Make a key once with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" and export it as STATE_KEY.

# state_store.py
import json, os
from cryptography.fernet import Fernet

fernet = Fernet(os.environ["STATE_KEY"])

def save_state(context, account):
    os.makedirs("state", exist_ok=True)
    blob = fernet.encrypt(json.dumps(context.storage_state()).encode())
    tmp = f"state/{account}.enc.tmp"
    with open(tmp, "wb") as f:
        f.write(blob)
    os.replace(tmp, f"state/{account}.enc")

def load_state(account):
    with open(f"state/{account}.enc", "rb") as f:
        return json.loads(fernet.decrypt(f.read()))

Expected output: a state_store.py module. Saving then loading a context returns the same cookies.

If it breaks: InvalidToken on load means the wrong key or a file written under an older one, so keep old keys until every file is re-saved.

Step 3: log in once by hand and save the state

# save_session.py
import sys
from playwright.sync_api import sync_playwright
from state_store import save_state

account = sys.argv[1]
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, finish MFA, wait for the dashboard, press enter: ")
    save_state(context, account)
    browser.close()

Run python save_session.py acct-01. This is the storage state approach from the Playwright auth docs: cookies and localStorage, saved as JSON before encryption.

Expected output: state/acct-01.enc exists.

If it breaks: cookies for other domains but none for the site means you saved before the login redirect finished, so wait for the dashboard. I keep MFA with a human on purpose and the agent never sees the code, the same hand-off as in human approval checkpoints for an AI agent.

Step 4: load the state and prove it works

from playwright.sync_api import TimeoutError as PlaywrightTimeout
from state_store import load_state

class SessionExpired(Exception):
    pass

def open_session(p, account):
    browser = p.chromium.launch()
    context = browser.new_context(storage_state=load_state(account))
    page = context.new_page()
    page.goto("https://app.example.com/dashboard")
    try:
        page.locator("[data-testid=account-menu]").wait_for(timeout=10_000)
    except PlaywrightTimeout:
        browser.close()
        raise SessionExpired(account)
    return browser, context, page

Check for something only a logged-in user sees. Checking that the login form is missing is a weaker test, because a blank error page passes it.

Expected output: the dashboard loads, the account menu appears, no redirect to /login.

If it breaks: cookies loaded but the page still looks logged out usually means a domain, path or SameSite mismatch. I wrote up the fixes in making injected storage state cookies actually stick.

Step 5: use a persistent profile when the file isn’t enough

Some sites keep logins in IndexedDB, service workers or device tokens that a JSON file won’t carry. Then let Chromium keep the whole profile with launch_persistent_context:

context = p.chromium.launch_persistent_context(
    user_data_dir="/data/profiles/acct-01",
    headless=True,
)
page = context.pages[0] if context.pages else context.new_page()

Three rules. One profile directory per account. One process per directory, because Chromium locks it. And never your everyday Chrome profile. Since Chrome 136 (stable April 2025), Chrome ignores the remote debugging switches on the default data directory, per the Chrome for Developers announcement, and I covered the symptom in Chrome 136 refusing to be driven over CDP. Also test that your login survives a restart before you trust a profile, because session cookies may not.

Expected output: the second run opens already logged in.

If it breaks: Chromium exits at once or says the profile is in use. Confirm no process holds it, then delete the Singleton* files in the profile directory.

Step 6: write the state back after every good run

Sites rotate session ids and refresh tokens. A file saved last Tuesday can be dead because the site rotated the value after your copy was taken. So save at the end of every run, and only after the logged-in check passes.

with sync_playwright() as p:
    browser, context, page = open_session(p, "acct-01")
    run_task(page)  # your agent
    page.locator("[data-testid=account-menu]").wait_for(timeout=5_000)
    save_state(context, "acct-01")  # skipped if the check above raised
    browser.close()

Expected output: the file’s modified time moves on every successful run.

If it breaks: if a logged-out state ever gets saved, your check is too weak. If the file goes stale after a week of green runs, some success path is skipping save_state.

Step 7: keep the exit IP and browser build stable

Some sites tie a session to the network it was created on. If your agent hops between cloud regions, the site may sign it out or ask to re-verify, and that’s the site doing its job. So keep one account on one exit IP and one browser build. I compared build options in Chrome for Testing vs open-source Chromium for a Playwright agent fleet, and the IP side is in how IP reputation is earned and lost.

If a fixed cloud IP does the job, use that. If your accounts are Singapore-facing and your host’s address keeps changing, a sticky session from Singapore Mobile Proxy keeps the same exit IP across a session. I run it, so weigh that accordingly. It sells real Singapore mobile IPs only, so it’s no help for accounts that need another country.

Pass it to Chromium with p.chromium.launch(proxy={"server": "http://host:port"}).

Expected output: the same exit IP at the start of every run for that account, which you should log.

If it breaks: ERR_TUNNEL_CONNECTION_FAILED on launch is a proxy path problem, see fixing ERR_TUNNEL_CONNECTION_FAILED for a cloud browser agent.

Step 8: detect expiry and hand back to a human

try:
    browser, context, page = open_session(p, account)
except SessionExpired:
    mark_needs_login(account)  # write status to your db, notify a person
    return

Never retry a login in a loop. Repeated failed logins can lock an account, and the agent can’t read an MFA code it was never given. Mark the account, stop, and let a person re-run save_session.py.

Expected output: a run against a dead session ends as needs_login inside your 10 second timeout, with no further attempts.

If it breaks: the agent keeps working against a logged-out page, which means the step 4 check tests for absence, not presence. Fix the selector.

Common pitfalls

  • Saving state after a failed run. A logged-out file overwrites a good one and you find out tomorrow. Save only after the logged-in check passes.
  • Sharing one profile or state file across parallel workers. Chromium locks the profile directory, and two workers writing one file means the last write wins, stale or not. Give each session to one worker at a time.
  • Assuming everything travels. As of September 2026 Playwright’s auth docs say there is no API for sessionStorage, so read it with page.evaluate and restore it with add_init_script. IndexedDB support in storage_state is newer, so check the release notes for your version before relying on it.
  • Borrowing cookies from someone’s daily browser. Even with consent, the session now lives in two places, and Chrome 136 makes that route harder anyway. Give the agent its own login, and check each site’s terms on automated access. This is not legal advice.
  • Treating a 200 response as a logged-in session. Plenty of sites return 200 with a login page, so assert on a logged-in element, not a status code.

Scaling this

  • 10x: ten or so accounts. Encrypted files on a mounted volume, one per account, a lock file per account, and a nightly job that runs step 4 against each and prints the dead ones. A person re-logging in by hand once in a while is fine at this size.
  • 100x: files move to a central store, Postgres or an encrypted bucket, with a row per session: account, exit IP, browser build, last verified, status. Workers lease a session before they touch it. The nightly check becomes a scheduled job with alerts, and re-logins go to a queue where a person approves each one.
  • 1000x: the limit stops being code and becomes human minutes spent on re-logins. Decide which sessions are worth renewing, prefer state files over profiles because profiles take real disk (measure yours), and move any site that has an API onto API tokens. Re-read each site’s terms on automated access too, because what’s fine at 10 accounts may not be at 1000.

Where to go next

More guides are in the blog index. Three I’d read next:

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-24.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →