all posts

Keeping a browser logged in without storing passwords

Ajay Kumar··8 min read

Watch a browser automation run and the first thirty seconds are almost always the same: navigate to a login page, fill two fields, submit, wait for a redirect, possibly handle a second factor. Repeated on every run, in every parallel worker, against a login flow that changes without warning.

It's the slowest part and the most fragile part, and it's the part that requires you to have credentials available in the automation environment — which is its own problem. There are three ways to avoid it, and they preserve progressively more.

Level one: storage state

Log in once, export the cookies and local storage, and inject them into every subsequent browser context. Playwright makes this a one-liner and it's the standard approach for test suites.

// once, in global setup
const page = await browser.newPage();
await page.goto("https://app.example.com/login");
await page.fill("#email", process.env.TEST_EMAIL);
await page.fill("#password", process.env.TEST_PASSWORD);
await page.click("button[type=submit]");
await page.waitForURL("**/dashboard");
await page.context().storageState({ path: "auth.json" });

// in every test — no login, straight to the authenticated page
const context = await browser.newContext({ storageState: "auth.json" });

Cheap, portable, and it works across machines. What it doesn't carry is everything outside cookies and local storage: IndexedDB contents, service worker registrations and caches, the WebSocket your app opened on load, and any in-memory state the application built after authenticating.

For most applications that's fine — the session token is the thing that matters. For applications that do meaningful work after login (sync an offline store, establish a realtime connection, hydrate a large client-side cache) you skip the login and then wait for all of that anyway.

auth.json contains a live session token. It is a credential — treat it exactly like a password. Gitignore it, never print it in CI logs, and give it a short lifetime. A leaked storage state file is an authenticated session someone else can use.

Level two: a persistent browser profile

Instead of exporting a subset, keep the whole browser profile directory and launch against it.

// Everything Chrome would remember: cookies, IndexedDB, service workers,
// extensions, permission grants, cached resources.
const context = await chromium.launchPersistentContext("/data/profiles/acct-1", {
  headless: true,
});

This keeps considerably more, including things that are otherwise painful to set up: granted permissions for camera or notifications, installed extensions, and a warm HTTP cache that makes page loads noticeably faster.

The costs are real though. Profile directories get large and grow over time. They're locked by a running browser, so two workers cannot share one — you need a profile per concurrent session. And they accumulate state, so a profile that's been used for a month has cruft that can make behaviour differ from a real user's, which is precisely the thing you were trying to reproduce.

Level three: snapshot the machine

The third option is to stop thinking about the browser's state and capture the whole machine — memory included — after it has reached the state you want.

import { Sandbox } from "@pandastack/sdk";

// Once: get to the exact state you want to start from
const base = await Sandbox.create({ template: "browser" });
await base.exec("node scripts/login-and-open-dashboard.js");
const ready = await base.snapshot();

// Every run: restore that state, browser already running and logged in
const s = await Sandbox.fork(ready);
// The page is loaded. The session is live. The WebSocket is connected.
// No login, no page load, no waiting for hydration.

The difference from the other two is that memory is part of the snapshot. The browser process is running, the page is rendered, the JavaScript heap is populated, the realtime connection was established. You're not restoring credentials and replaying setup — you're resuming a machine that was already there.

For workloads where every run repeats an expensive preamble — log in, navigate three levels deep, wait for a heavy dashboard to render — this collapses to a sub-second restore. And each fork is genuinely independent, so a hundred parallel runs all start from the identical warm state without sharing a profile directory or contending on a lock.

Where snapshots get complicated

Three things to know before relying on this, because they're the ones that produce confusing behaviour.

  • Sessions still expire. A snapshot taken on Monday holds a token that may be invalid by Friday. Rebuild the snapshot on a schedule, and have your automation detect a redirect to the login page and fall back to a full login rather than failing.
  • Network connections don't survive. That WebSocket was connected to a socket that no longer exists; on restore it will error and your client needs to reconnect. Well-built applications do this automatically — it's the same code path as recovering from a laptop waking from sleep.
  • The clock is frozen at snapshot time. A restored guest believes it's whenever the snapshot was taken until something corrects it, and skewed clocks break TLS certificate validation in ways that look like network failures. Any platform doing this seriously syncs the clock on restore — worth confirming rather than assuming.

The security argument for snapshots

There's a benefit here beyond speed that I think is underrated.

With the storage-state approach, credentials must be available in the automation environment — the environment that also runs whatever code you're testing, or that an AI agent is driving. If that environment is compromised, the password goes with it.

With a snapshot, login happens once in a controlled build step. The snapshot contains a session, not a password. The runtime environment never sees the credential, and a compromised run leaks a session token that you can revoke rather than a password that may be reused elsewhere.

That's a meaningful improvement for anything giving an autonomous agent browser access. The agent gets an authenticated browser; it never gets the ability to authenticate as you somewhere else.

Which to use

  1. Storage state, if your login is a form and your app is mostly server-rendered. It's the simplest thing that works and it runs anywhere.
  2. A persistent profile, if you need permissions, extensions, or a warm cache — and you can afford one profile per concurrent worker.
  3. A machine snapshot, if the setup after login is expensive, if you need many identical parallel sessions, or if you'd rather credentials never entered the runtime environment at all.
  4. Regardless of choice: detect the logged-out state and re-authenticate rather than failing. Every one of these expires eventually, and the difference between a robust automation and a fragile one is usually just that fallback path.

Frequently asked questions

How do I skip the login step in browser automation?

The simplest approach is storage state: log in once in a setup step, export cookies and local storage to a file, and inject that into every subsequent browser context. Playwright supports this directly with context.storageState() and the storageState option on newContext(). It is portable across machines and takes about three lines. What it does not carry is IndexedDB, service workers, established WebSocket connections, or any in-memory state your application built after authenticating — which matters if your app does significant work post-login.

Is a saved storage state file a security risk?

Yes — it contains a live session token and should be treated exactly like a password. Gitignore it, never print it in CI logs, keep its lifetime short, and be aware that anyone who obtains it holds an authenticated session until it expires or is revoked. The upside compared to storing the password itself is that a session can be revoked centrally and typically cannot be reused on other services, whereas a leaked password often can.

What does a machine snapshot capture that a browser profile doesn't?

Memory. A profile directory holds cookies, IndexedDB, service workers and cached files, but the browser still has to start, load the page and execute JavaScript. A machine snapshot captures the running process: the page is already rendered, the JavaScript heap is populated, and the application has finished whatever hydration it does on load. Restoring resumes that state rather than rebuilding it, so an expensive preamble — log in, navigate several levels deep, wait for a heavy dashboard — collapses to a sub-second restore.

What breaks when you restore a browser from a snapshot?

Three things. Sessions expire, so a snapshot taken on Monday may hold an invalid token by Friday — rebuild on a schedule and detect the login redirect as a fallback. Network connections do not survive: any WebSocket was connected to a socket that no longer exists, so the client must reconnect, which well-built applications already do as part of recovering from sleep. And the guest clock is frozen at snapshot time, which breaks TLS certificate validation in ways that look like network errors unless the platform syncs the clock on restore.

Is snapshotting a logged-in browser more secure than storing credentials?

For many setups, yes. With the credential approach, the password must be available in the environment that runs your automation — the same environment executing test code or being driven by an agent — so compromising that environment leaks the password. With a snapshot, login happens once in a controlled build step and the snapshot contains a session rather than a password. The runtime never sees the credential, so a compromised run exposes a revocable session token instead of a password that may be reused elsewhere.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.