cdpfleet Docs GitHub Dashboard

Cases / #39 · 2026-10-12 · Medium

Saved the login, still logged out: storageState and IndexedDB

Firebase Auth, and apps built on SDKs that persist to IndexedDB, keep the login there, which storageState leaves out by default. One option brings it along; Map and Blob values still don't survive.

Chromium

Run on production on 2026-10-12: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

You log in once, save the state with storageState(), restore it in the next session — and the app greets you with a sign-in form. The cookies and localStorage came back, but the login wasn't in them: Firebase Auth, and apps built on SDKs that persist to IndexedDB, keep the signed-in user and its refresh token in IndexedDB (most other auth SDKs default to localStorage, sessionStorage or memory). By default Playwright doesn't save IndexedDB at all. Case 11 carried cookies and localStorage between browsers; this one carries the rest.

What we used, and why

WhatWhy
A Firebase-shaped loginThe record Firebase Auth's web SDK writes after sign-in: database firebaseLocalStorageDb, store firebaseLocalStorage, key firebase:authUser:<apiKey>:[DEFAULT], the user and their tokens as the value. Plus a cookie and a localStorage value, the way most sites mix them.
storageState()The default export: cookies and each origin's localStorage.
storageState({ indexedDB: true })Also exports every IndexedDB database of every origin the context visited (Playwright 1.51+). Python indexed_db=True, Java setIndexedDB(true), C# IndexedDB = true, Go IndexedDB: playwright.Bool(true).
newContext({ storageState })Restores the file before the first request, IndexedDB included when the file has it. No option needed on this side. In Go: context.SetStorageState(path).
A store of non-JSON valuesDate, Uint8Array, BigInt, Map and Blob in IndexedDB, to see which ones survive the trip through JSON.
Two sessionsThe first browser is gone before the second starts; only the files carry the login.

How it works

  1. Session 1: set a session cookie on httpbin.org, store a theme in localStorage and write the Firebase-shaped user and the typed values to IndexedDB.
  2. Save the state twice: once with the defaults, once with IndexedDB.
  3. Close the browser. Session 2, a new browser: restore each file into its own context.
  4. In each, check the cookie the site receives, the localStorage value and whether the app would find its signed-in user — then which IndexedDB value types came back as what.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
import { readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

const KEY = process.env.CDPFLEET_API_KEY;
const ORIGIN = 'https://httpbin.org';
// Both files hold a live login: keep them somewhere safe.
const PLAIN = path.join(tmpdir(), 'cdpfleet-state.json');
const WITH_IDB = path.join(tmpdir(), 'cdpfleet-state-idb.json');

async function launch() {
  const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: true }),
  });
  if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
  return res.json();
}

// What a Firebase Auth web app keeps after sign-in: one record in IndexedDB, nothing in
// cookies or localStorage. Plus a store of values that aren't plain JSON.
const WRITE = async () => {
  const open = (name, setup) => new Promise((ok, no) => {
    const r = indexedDB.open(name, 1);
    r.onupgradeneeded = () => setup(r.result);
    r.onsuccess = () => ok(r.result);
    r.onerror = () => no(r.error);
  });
  const put = (db, store, value, key) => new Promise((ok, no) => {
    const tx = db.transaction(store, 'readwrite');
    tx.objectStore(store).put(value, key);
    tx.oncomplete = ok;
    tx.onerror = () => no(tx.error);
  });
  const fb = await open('firebaseLocalStorageDb', (db) => db.createObjectStore('firebaseLocalStorage', { keyPath: 'fbase_key' }));
  await put(fb, 'firebaseLocalStorage', {
    fbase_key: 'firebase:authUser:AIzaDemoKey:[DEFAULT]',
    value: { uid: 'u_8f3a', email: '[email protected]', stsTokenManager: { refreshToken: 'demo-refresh-token', accessToken: 'demo-access-token', expirationTime: Date.now() + 3600000 } },
  });
  const types = await open('typesDb', (db) => db.createObjectStore('values'));
  const values = { date: new Date(0), bytes: new Uint8Array([1, 2, 3]), bigint: 10n, map: new Map([['k', 1]]), blob: new Blob(['hi']) };
  for (const [k, v] of Object.entries(values)) await put(types, 'values', v, k);
  localStorage.setItem('theme', 'dark');
};

// What the app finds on its next visit.
const READ = async () => {
  const dbs = (await indexedDB.databases()).map((d) => d.name);
  const get = (name, store, key) => new Promise((ok) => {
    if (!dbs.includes(name)) return ok(undefined);
    const r = indexedDB.open(name);
    r.onsuccess = () => {
      const q = r.result.transaction(store).objectStore(store).get(key);
      q.onsuccess = () => ok(q.result);
      q.onerror = () => ok(undefined);
    };
    r.onerror = () => ok(undefined);
  });
  const user = await get('firebaseLocalStorageDb', 'firebaseLocalStorage', 'firebase:authUser:AIzaDemoKey:[DEFAULT]');
  const types = {};
  for (const k of ['date', 'bytes', 'bigint', 'map', 'blob']) {
    const v = await get('typesDb', 'values', k);
    const t = v === undefined ? 'missing' : typeof v === 'bigint' ? 'BigInt' : Object.prototype.toString.call(v).slice(8, -1);
    types[k] = t === 'Object' && Object.keys(v).length === 0 ? 'Object {} (emptied)' : t;
  }
  return { local_storage_theme: localStorage.getItem('theme'), firebase_user: user ? user.value.email : null, value_types: types };
};

const cookieSent = async (page) => (await (await page.goto(`${ORIGIN}/cookies`, { timeout: 60000 })).json()).cookies.session === 'abc123';
const idbOrigins = (file) => JSON.parse(readFileSync(file, 'utf8')).origins.filter((o) => o.indexedDB?.length);

// Session 1: "sign in", then save the state twice — the default way and with IndexedDB.
const s1 = await launch();
const b1 = await chromium.connect(s1.wsUrl, { headers: { 'x-api-key': KEY } });
try {
  const ctx = await b1.newContext();
  const page = await ctx.newPage();
  await page.goto(`${ORIGIN}/cookies/set?session=abc123`, { timeout: 60000 });
  await page.evaluate(WRITE);
  await ctx.storageState({ path: PLAIN });
  await ctx.storageState({ path: WITH_IDB, indexedDB: true });
} finally {
  await b1.close();
}

// Session 2: a new browser. Restore each file into its own context and look around.
const s2 = await launch();
const b2 = await chromium.connect(s2.wsUrl, { headers: { 'x-api-key': KEY } });
try {
  const restored = [];
  let valueTypes;
  for (const [label, file] of [['cookies + localStorage', PLAIN], ['with IndexedDB', WITH_IDB]]) {
    const ctx = await b2.newContext({ storageState: file });
    const page = await ctx.newPage();
    const cookie = await cookieSent(page);
    const seen = await page.evaluate(READ);
    restored.push({ state: label, cookie_sent: cookie, local_storage_theme: seen.local_storage_theme, firebase_user: seen.firebase_user, signed_in: seen.firebase_user !== null });
    if (file === WITH_IDB) valueTypes = seen.value_types;
    await ctx.close();
  }
  console.log(JSON.stringify({
    origin: ORIGIN,
    sessions: [s1.sessionId, s2.sessionId],
    state_files: [
      { state: 'cookies + localStorage', bytes: statSync(PLAIN).size, indexeddb_databases: idbOrigins(PLAIN).flatMap((o) => o.indexedDB.map((d) => d.name)) },
      { state: 'with IndexedDB', bytes: statSync(WITH_IDB).size, indexeddb_databases: idbOrigins(WITH_IDB).flatMap((o) => o.indexedDB.map((d) => d.name)) },
    ],
    restored,
    value_types_after_restore: valueTypes,
  }, null, 2));
} finally {
  await b2.close();
}

What we got

State fileFile size (bytes)Cookie sentlocalStorageFirebase user in IndexedDBApp signed in
cookies + localStorage403yesdark—no
with IndexedDB2551yesdark[email protected]yes

From the Node.js run on 2026-10-12. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "origin": "https://httpbin.org",
  "sessions": [
    "0098446e-ada1-4746-a61b-94c8d93cce96",
    "92a2fc86-8733-4f28-be01-5e2efa9e8548"
  ],
  "state_files": [
    {
      "state": "cookies + localStorage",
      "bytes": 403,
      "indexeddb_databases": []
    },
    {
      "state": "with IndexedDB",
      "bytes": 2551,
      "indexeddb_databases": [
        "firebaseLocalStorageDb",
        "typesDb"
      ]
    }
  ],
  "restored": [
    {
      "state": "cookies + localStorage",
      "cookie_sent": true,
      "local_storage_theme": "dark",
      "firebase_user": null,
      "signed_in": false
    },
    {
      "state": "with IndexedDB",
      "cookie_sent": true,
      "local_storage_theme": "dark",
      "firebase_user": "[email protected]",
      "signed_in": true
    }
  ],
  "value_types_after_restore": {
    "date": "Date",
    "bytes": "Uint8Array",
    "bigint": "BigInt",
    "map": "Object {} (emptied)",
    "blob": "Object {} (emptied)"
  }
}

Takeaways