SessionCourier install now ↗

guides

Export your session as Playwright storageState

Turn a logged-in browser session into a storageState.json that Playwright and Puppeteer tests can reuse — no scripted login flows.

Why storageState

Playwright can start a browser context that is already authenticated, by loading a JSON file — its storageState — that holds the cookies (and, in general, localStorage) of a logged-in session. Most teams generate that file by scripting a login flow: type the username, type the password, click submit, save the state. That works until it doesn’t — captchas, SSO redirects, and 2FA prompts all break a scripted login, and maintaining the script is ongoing work.

SessionCourier takes the other route: it exports the session you already have in your browser. Log in manually once — however awkward the login is — export the state, and your tests reuse it headless. No login code to write, nothing to re-script when the login page changes.

Export the file

  1. Log in to the target site in a normal browser tab.
  2. Open SessionCourier on that tab.
  3. Choose the Playwright storageState export.
  4. If your app keeps its token in localStorage, make sure Include this tab’s localStorage is ticked on that row before exporting.
  5. Save it as auth.json (or any name you like).

Exported from the site view, the file carries the site’s cookies and that tab’s localStorage in the origins array — the box is ticked by default there. Untick it for a cookies-only file: enough for apps whose session is a cookie, and the smaller file.

Apps that keep the token in localStorage

Many SPA and OAuth stacks do. For those, cookies alone restore the session but not the logged-in state — the context comes up unauthenticated. Ticking Include this tab’s localStorage fills Playwright’s origins array with the current origin’s localStorage, which newContext({ storageState }) restores alongside the cookies:

{
  "cookies": [
    {
      "name": "session",
      "value": "eyJhbGciOiJIUzI1NiIs…",
      "domain": ".staging.example.com",
      "path": "/",
      "expires": 1789430400,
      "httpOnly": true,
      "secure": true,
      "sameSite": "Lax"
    }
  ],
  "origins": [
    {
      "origin": "https://staging.example.com",
      "localStorage": [{ "name": "access_token", "value": "eyJhbGciOiJIUzI1NiIs…" }]
    }
  ]
}

The round trip works inside the browser too: importing such a file back into SessionCourier offers to restore those entries into the matching tab — see Copying a session to another browser.

Two limits worth knowing, both consequences of how browsers work rather than choices:

  • It is the active tab’s localStorage, and only that. Unlike cookies, which the browser exposes through an API, localStorage is reachable only from a live document of its own origin — so there is no way to collect it for several sites at once. Export from the tab you are logged in to.

  • sessionStorage and IndexedDB are not included. sessionStorage has no slot in Playwright’s format at all — a long-standing upstream request that was declined; the Playwright docs give a addInitScript snippet as the workaround. IndexedDB is supported by Playwright itself (its storageState({ indexedDB: true }) option, added in 1.51), but SessionCourier does not capture it. So if your stack keeps its session in either of those, a storageState export from us will not carry it, and ticking the box will not change that.

    Worth knowing for one common case: Firebase Auth stores its session in IndexedDB, but it also migrates a firebase:authUser:<apiKey>:[DEFAULT] entry found in localStorage into IndexedDB on the next load — so a localStorage capture can still work there, depending on what your app left behind.

Copying a session to another browser

The same file moves a session between two browsers, not just into Playwright. Export it on the machine you are logged in on, open the site in the other browser, and import the file there: SessionCourier writes the cookies, then offers to write that origin’s localStorage into the open tab. It shows you the origin and the names of the entries it will set before it writes anything, and it only ever adds or replaces those entries — nothing else in that site’s storage is touched.

Most apps read their token once, when the page loads, so the restore usually shows nothing until you reload. SessionCourier offers a Reload button rather than reloading for you, because reloading would throw away whatever you had on screen.

It is a copy, not a share. Both browsers now hold the same token, and for any app using rotating refresh tokens — the normal setup for a modern SPA, and a recommendation of RFC 9700 — that is a problem the tool cannot solve. Whichever browser refreshes second presents a token the server has already retired, and a server following the spec treats that as a stolen token and revokes the whole chain: both sessions log out, including the one you copied from. Use the copy in one place at a time.

Some stacks keep nothing we can carry. Not a defect, and not worth debugging as one — these keep their session somewhere a localStorage capture cannot reach:

  • MSAL / Microsoft Entra IDsessionStorage by default, and MSAL v4 encrypts its localStorage cache under a key held in a session cookie.
  • Keycloak JS — in memory only. Its one piece of web storage is the kc-callback-<state> redirect scratch, which is not a session.
  • Auth0 at its default in-memory cache. Configuring it for localStorage makes it carryable.

Apps whose session is an httpOnly cookie — the BFF pattern — are covered by the cookie half and need none of this.

Use it in Playwright

Point the whole project at the file so every test starts authenticated:

// playwright.config.ts — every test starts authenticated
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { storageState: 'auth.json' },
});

Or scope it to a single test file or project:

// or per test file / project
import { test } from '@playwright/test';

test.use({ storageState: 'auth.json' });

test('dashboard loads for a logged-in user', async ({ page }) => {
  await page.goto('https://staging.example.com/dashboard');
  await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
});

With the raw library API, pass it when you create the context:

// library API
const context = await browser.newContext({ storageState: 'auth.json' });
const page = await context.newPage();

Use it in Puppeteer

Puppeteer has no storageState option, but you don’t need it: the cookies array inside the same file uses exactly the fields Puppeteer’s page.setCookie accepts — name, value, domain, path, expires, httpOnly, secure, sameSite (with expires: -1 marking a session cookie). So feed Puppeteer the cookies array from the storageState export:

import fs from 'node:fs';
import puppeteer from 'puppeteer';

const { cookies } = JSON.parse(fs.readFileSync('auth.json', 'utf8'));
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setCookie(...cookies);
await page.goto('https://staging.example.com/dashboard');

Do not feed Puppeteer the plain JSON export instead — that one uses Chrome’s cookie shape (expirationDate, lowercase sameSite values like lax), which setCookie doesn’t understand. Use the storageState export here.

(Note for newer Puppeteer: page.setCookie is deprecated in favor of browser.setCookie(...cookies) since v23.)

Pitfalls

  • Session cookies expire. Short-lived sessions time out; when tests suddenly start redirecting to the login page, re-export a fresh auth.json.
  • __Host- and __Secure- prefixes keep their constraints. Cookies with these prefixes only work over HTTPS (and __Host- is bound to the exact host with path=/) — your test URL has to be https://, not http://localhost.
  • SameSite maps to Playwright’s set. Values normalize to "Strict" | "Lax" | "None"; None cookies also need secure: true, which the export already carries.
  • Never commit real-account state. auth.json is a live credential. Add it to .gitignore and use dedicated test accounts, not your own login.
# .gitignore
auth.json
auth/

In CI

Today, export the file locally and hand it to CI as a secret or build artifact, then point storageState at it — no browser or login runs in the pipeline. That already covers most needs.

A fully headless path — a CLI that can pull a synced profile from inside the pipeline, no browser at all — is planned for Pro. Until it lands, the export-and-secret workflow above is the recommended approach.