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
- Log in to the target site in a normal browser tab.
- Open SessionCourier on that tab.
- Choose the Playwright
storageStateexport. - If your app keeps its token in
localStorage, make sure Include this tab’s localStorage is ticked on that row before exporting. - 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,localStorageis 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. -
sessionStorageand IndexedDB are not included.sessionStoragehas no slot in Playwright’s format at all — a long-standing upstream request that was declined; the Playwright docs give aaddInitScriptsnippet as the workaround. IndexedDB is supported by Playwright itself (itsstorageState({ 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 inlocalStorageinto 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 ID —
sessionStorageby default, and MSAL v4 encrypts itslocalStoragecache 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
localStoragemakes 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 withpath=/) — your test URL has to behttps://, nothttp://localhost.- SameSite maps to Playwright’s set. Values normalize to
"Strict" | "Lax" | "None";Nonecookies also needsecure: true, which the export already carries. - Never commit real-account state.
auth.jsonis a live credential. Add it to.gitignoreand 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.