Smoketest

Persisting Auth Sessions in Stagehand v3

Stagehand v3 removed storageState() and userDataDir persistence is reported broken, so your agent logs in on every run. The reliable fix today is exporting cookies with context.cookies() and re-importing them with addCookies(), with Browserbase Contexts as the cloud equivalent.

9 min read

Your Stagehand agent works, except it logs in from scratch on every single run. You set userDataDir, nothing got saved. You reached for storageState() like in Playwright, and got stagehand.context.storageState is not a function. Now you're reading a GitHub issue thread instead of shipping.

Stagehand v3 removed storageState() and its userDataDir option is reported broken (issue #1250, open since November 2025). The working fix: export cookies after login with stagehand.context.cookies(), save them to a file, and restore them on the next run with context.addCookies(). On Browserbase, use Contexts instead.

This post covers why v3 broke it, the native cookie pattern nobody in the issue thread uses, the userDataDir situation as it actually stands, a small community plugin, and the cloud path. Everything here is verified against Stagehand 3.6 and the official docs as of July 2026.

Why v3 broke session persistence

Stagehand v3 is a rewrite. It dropped the Playwright dependency entirely and talks to Chromium directly over CDP. Browserbase's stated reasons are speed (they claim 44% faster on complex DOM interactions, by their account) and direct control for iframes, multi-tab, and accessibility-tree streaming.

The collateral damage: storageState() was a Playwright context method. When Playwright left, it left too, and nothing replaced it. Issue #1250, filed November 9, 2025 against v3.0.1, reports the full extent:

  • stagehand.context.storageState is not a function
  • page.context is not a function
  • The userDataDir directory "is never created", with test output showing Directory exists after init: false even with preserveUserDataDir: true set

As of July 2026 that issue is still open, with no linked fix PR and no maintainer resolution beyond a "I will take a look" from November 2025. Comments asking for updates in February and May 2026 went unanswered.

There's history here. In the v2 era, issue #794 reported that stagehand.close() unconditionally ran fs.rmSync(this.contextPath, { recursive: true, force: true }), deleting the user data dir on every close. That was closed as completed in June 2025 when the preserveUserDataDir flag shipped. Then v3 landed and #1250 reported the same options silently doing nothing. Fixed once, regressed in spirit.

So if you searched "stagehand login every run" and landed on a raw GitHub issue, that's the state of the world. Here's what actually works.

Here's the part the #1250 thread misses: v3 removed storageState(), but it ships first-class cookie APIs on the context. context.cookies(), context.addCookies(), and context.clearCookies() are all documented, and because they go through CDP rather than document.cookie, they include HttpOnly cookies. That matters: most session cookies worth persisting are HttpOnly, and the page.evaluate(() => document.cookie) workarounds people post in the thread silently miss them.

Save the session after logging in:

save-session.ts

TypeScript
import { Stagehand } from "@browserbasehq/stagehand";
import fs from "node:fs";

const stagehand = new Stagehand({ env: "LOCAL" });
await stagehand.init();

const page = await stagehand.context.awaitActivePage();
await page.goto("https://app.example.com/login");
// ... perform login via act() or manual steps ...

// CDP-backed: includes HttpOnly cookies
const cookies = await stagehand.context.cookies();
fs.writeFileSync("session.json", JSON.stringify(cookies, null, 2));

await stagehand.close();

Restore it on the next run, before navigating anywhere:

restore-session.ts

TypeScript
import { Stagehand } from "@browserbasehq/stagehand";
import fs from "node:fs";

const stagehand = new Stagehand({ env: "LOCAL" });
await stagehand.init();

const saved = JSON.parse(fs.readFileSync("session.json", "utf8"));
await stagehand.context.addCookies(saved);

const page = await stagehand.context.awaitActivePage();
await page.goto("https://app.example.com/dashboard"); // already logged in

Three gotchas that will bite you:

  1. The url vs domain/path validation. Per the context reference, each cookie passed to addCookies() must have either url or both domain and path. Providing url alongside domain or path throws a validation error. Cookies exported by cookies() carry domain and path, so this round-trips cleanly, but if you merge in cookies from another source, strip the url field first. Also: sameSite: "None" requires secure: true.
  2. Session cookies don't survive. A cookie with expires: -1 is a session cookie. You can write it back, but the server may have already invalidated the session it pointed to. Prefer sites' "remember me" paths, which set long-lived cookies.
  3. Cookies aren't the whole story. Plenty of SPAs keep the JWT in localStorage, not cookies. There's no localStorage export API in v3, so handle it manually: export with page.evaluate(() => JSON.stringify(localStorage)), then re-import on the next run with context.addInitScript(), which runs before page scripts on every navigation, or with an evaluate call after landing on the right origin.

This pattern works identically with env: "LOCAL" and env: "BROWSERBASE", needs no extra dependencies, and doesn't depend on the disputed userDataDir machinery at all. If you only take one thing from this post, take this one.

Where userDataDir actually stands

The browser configuration docs still document the profile-based approach:

stagehand.config.ts

TypeScript
const stagehand = new Stagehand({
  env: "LOCAL",
  localBrowserLaunchOptions: {
    userDataDir: "./profile",
    preserveUserDataDir: true, // without this, expect the dir to be deleted on close
  },
});

Two problems. First, the docs describe preserveUserDataDir as "keep data after closing" but don't state its default; given that #794's whole bug was deletion-on-close, never rely on the default, set it explicitly. Second, and bigger: #1250 reports that as of v3.0.1 both options are ignored entirely. The directory is never created, and pre-creating it manually leaves it empty (Directory contents: 0 items). The issue remains open with no fix PR linked, so we can't tell you it's fixed in 3.6.0, and neither can the changelog.

Our advice: treat userDataDir as documented-but-unproven in v3. Try it in your setup by all means, it's four lines. But build your persistence on the cookie pattern above so your runs don't depend on it.

Full storageState parity via Playwright attach

If you genuinely need Playwright's one-call storageState({ path }) export (cookies plus origin storage in a single JSON file), you can attach Playwright to the browser Stagehand launched. The official Playwright integration documents the connection:

playwright-attach.ts

TypeScript
import { chromium } from "playwright-core";

const browser = await chromium.connectOverCDP({
  wsEndpoint: stagehand.connectURL(),
});
const pwContext = browser.contexts()[0];

await pwContext.storageState({ path: "state.json" }); // save
// or: await pwContext.addCookies(saved);              // restore

This is the workaround the #1250 thread converged on, and it works, at the cost of reintroducing the dependency v3 exists to remove. One caveat: issue #1392 reports init failures when passing a Playwright page obtained via connectOverCDP back into Stagehand, so keep the Playwright handle for storage operations and let Stagehand drive through its own context. Treat this as the escape hatch, not the default.

The community plugin

There's a small wrapper that packages the cookie pattern: stagehand-session-persist (MIT, TypeScript). StagehandSession wraps the Stagehand constructor, auto-loads cookies on init(), and auto-saves on close(), with named sessions stored as JSON under ~/.stagehand-sessions by default:

plugin-example.ts

TypeScript
import { StagehandSession } from "stagehand-session-persist";

const session = new StagehandSession(
  { verbose: 1, env: "LOCAL" },
  { sessionName: "gmail", storageDir: "~/.my-sessions" }
);
await session.init();
await session.page.goto("https://gmail.com");
await session.close(); // auto-saves

Sizing it honestly: 8 stars, 6 commits, zero issues or PRs at the time of writing. The README documents cookie persistence and says nothing about localStorage, so assume it's cookies-only. It saves you thirty lines of code, not a category of problem. Fine as a stopgap, but it's the same pattern you just read, and you can own those thirty lines yourself.

Browserbase Contexts, the cloud path

If you run on Browserbase rather than locally, Contexts are the supported answer, and they cover more than cookies: localStorage, IndexedDB, session storage, service workers, and browser preferences all persist. The one documented exclusion is the HTTP cache (images, CSS, JS, fonts). Contexts are encrypted at rest.

browserbase-context.ts

TypeScript
import { Browserbase } from "@browserbasehq/sdk";

const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY! });
const context = await bb.contexts.create();

const session = await bb.sessions.create({
  browserSettings: {
    context: { id: context.id, persist: true },
  },
});

persist: true writes changes back to the context when the session closes; persist: false gives you read-only reuse of the stored state, useful when parallel sessions share one login. With Stagehand, the same browserSettings.context object goes through the constructor's browserbaseSessionCreateParams, which passes through to the session-create call; Browserbase ships a ready-made template (npx create-browser-app --template context) showing the wiring.

One thing the docs are upfront about: contexts live until you delete them, but the auth inside them doesn't. Cookies expire, passwords change, tokens get revoked server-side. Build a logged-out check into your runs and re-authenticate when it trips, whatever persistence layer you use.

Three features that sound the same and aren't

The search queries around this problem conflate three different mechanisms. Quick map:

  • keepAlive: true + browserbaseSessionID keeps one browser session running after stagehand.close() so you can reconnect to it. Same live browser, same process. This is reconnection, not persistence; when the session eventually dies, everything in it is gone.
  • Browserbase Contexts persist storage across separate sessions in the cloud. New browser each time, restored state.
  • userDataDir is the local equivalent of a Context: a Chromium profile on your disk. Documented in v3, reported non-functional in #1250.

If your agent needs to survive a script crash mid-run, you want the first. If it needs to skip login tomorrow, you want the second or third, or the cookie pattern that doesn't depend on either.

Where Smoketest fits

The cookie export pattern above works, and if you're building a general-purpose agent you should own it. But if the reason you're persisting auth is to re-run the same logged-in user tests after every deploy (can a user log in, reach the dashboard, complete checkout), you're maintaining session plumbing that a checking tool should handle for you. Smoketest runs described tests in a real browser and manages the session lifecycle itself, so a regression in your app is the only thing that turns a run red.

FAQ

Does Stagehand v3 have storageState?

No. storageState() was a Playwright context method, and Stagehand v3 removed the Playwright dependency in its CDP rewrite. Calling it throws stagehand.context.storageState is not a function. The replacements are context.cookies() / context.addCookies() for cookies, manual page.evaluate for localStorage, or attaching Playwright over CDP via stagehand.connectURL() for full parity.

Why does Stagehand delete my userDataDir on close?

Historically, stagehand.close() removed the profile directory with fs.rmSync unless you opted out, which is why the preserveUserDataDir flag exists (issue #794). Always set preserveUserDataDir: true explicitly. Note that issue #1250 reports both options being ignored entirely in v3, so verify the directory actually populates before relying on it.

Do Stagehand's cookie APIs include HttpOnly cookies?

Yes. context.cookies() in v3 is CDP-backed, so it returns HttpOnly cookies along with everything else, including their httpOnly, secure, and sameSite attributes. That's the key advantage over scraping document.cookie in a page.evaluate call, which cannot see HttpOnly cookies and will silently drop most real session tokens.

How do I stop Stagehand logging in on every run with Browserbase?

Create a Context once with bb.contexts.create(), then start each session with browserSettings: { context: { id, persist: true } }. Cookies, localStorage, and IndexedDB carry over between sessions; the HTTP cache does not. Pass the same settings through Stagehand's browserbaseSessionCreateParams. Stored auth still expires server-side, so detect logged-out state and re-authenticate.

Is stagehand-session-persist production ready?

It's a small MIT-licensed wrapper (8 stars, 6 commits at the time of writing) that auto-saves and auto-loads cookies as JSON around Stagehand's lifecycle. It works for cookie-based auth, but its README doesn't document localStorage support, so assume cookies only. For anything load-bearing, implement the same thirty-line pattern yourself and own it.

Share this post

The QA column is no longer where the sprint goes to die.

Move one ticket. Watch it come back tested. Then decide.

Keep reading

All posts →