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.
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
| What | Why |
|---|---|
| A Firebase-shaped login | The 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 values | Date, Uint8Array, BigInt, Map and Blob in IndexedDB, to see which ones survive the trip through JSON. |
| Two sessions | The first browser is gone before the second starts; only the files carry the login. |
How it works
- 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.
- Save the state twice: once with the defaults, once with IndexedDB.
- Close the browser. Session 2, a new browser: restore each file into its own context.
- 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:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// 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();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import tempfile
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
ORIGIN = "https://httpbin.org"
# Both files hold a live login: keep them somewhere safe.
PLAIN = os.path.join(tempfile.gettempdir(), "cdpfleet-state.json")
WITH_IDB = os.path.join(tempfile.gettempdir(), "cdpfleet-state-idb.json")
def launch():
r = requests.post(
"https://starter.cdpfleet.com/chromium/session",
headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": True},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"launch: {r.status_code} {r.text}")
return r.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.
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.
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 };
}"""
def cookie_sent(page):
return page.goto(f"{ORIGIN}/cookies", timeout=60000).json()["cookies"].get("session") == "abc123"
def idb_databases(file):
with open(file) as f:
origins = json.load(f)["origins"]
return [d["name"] for o in origins for d in o.get("indexedDB") or []]
with sync_playwright() as p:
# Session 1: "sign in", then save the state twice: the default way and with IndexedDB.
s1 = launch()
b1 = p.chromium.connect(s1["wsUrl"], headers={"x-api-key": KEY})
try:
ctx = b1.new_context()
page = ctx.new_page()
page.goto(f"{ORIGIN}/cookies/set?session=abc123", timeout=60000)
page.evaluate(WRITE)
ctx.storage_state(path=PLAIN)
ctx.storage_state(path=WITH_IDB, indexed_db=True)
finally:
b1.close()
# Session 2: a new browser. Restore each file into its own context and look around.
s2 = launch()
b2 = p.chromium.connect(s2["wsUrl"], headers={"x-api-key": KEY})
try:
restored, value_types = [], None
for label, file in [("cookies + localStorage", PLAIN), ("with IndexedDB", WITH_IDB)]:
ctx = b2.new_context(storage_state=file)
page = ctx.new_page()
cookie = cookie_sent(page)
seen = page.evaluate(READ)
restored.append({
"state": label,
"cookie_sent": cookie,
"local_storage_theme": seen["local_storage_theme"],
"firebase_user": seen["firebase_user"],
"signed_in": seen["firebase_user"] is not None,
})
if file == WITH_IDB:
value_types = seen["value_types"]
ctx.close()
print(json.dumps({
"origin": ORIGIN,
"sessions": [s1["sessionId"], s2["sessionId"]],
"state_files": [
{"state": "cookies + localStorage", "bytes": os.path.getsize(PLAIN), "indexeddb_databases": idb_databases(PLAIN)},
{"state": "with IndexedDB", "bytes": os.path.getsize(WITH_IDB), "indexeddb_databases": idb_databases(WITH_IDB)},
],
"restored": restored,
"value_types_after_restore": value_types,
}, indent=2))
finally:
b2.close()
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().create();
static final String ORIGIN = "https://httpbin.org";
// Both files hold a live login: keep them somewhere safe.
static final Path PLAIN = Path.of(System.getProperty("java.io.tmpdir"), "cdpfleet-state.json");
static final Path WITH_IDB = Path.of(System.getProperty("java.io.tmpdir"), "cdpfleet-state-idb.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.
static final String 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.
static final String 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 };
}""";
static JsonObject launch() throws Exception {
String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": true}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
return JsonParser.parseString(res.body()).getAsJsonObject();
}
static boolean cookieSent(Page page) {
page.navigate(ORIGIN + "/cookies", new Page.NavigateOptions().setTimeout(60000));
JsonObject cookies = JsonParser.parseString(page.textContent("body")).getAsJsonObject().getAsJsonObject("cookies");
return cookies.has("session") && cookies.get("session").getAsString().equals("abc123");
}
static JsonArray idbDatabases(Path file) throws Exception {
JsonArray names = new JsonArray();
for (JsonElement o : JsonParser.parseString(Files.readString(file)).getAsJsonObject().getAsJsonArray("origins")) {
JsonElement idb = o.getAsJsonObject().get("indexedDB");
if (idb != null && idb.isJsonArray()) for (JsonElement d : idb.getAsJsonArray()) names.add(d.getAsJsonObject().get("name"));
}
return names;
}
static JsonObject stateFile(String label, Path file) throws Exception {
JsonObject f = new JsonObject();
f.addProperty("state", label);
f.addProperty("bytes", Files.size(file));
f.add("indexeddb_databases", idbDatabases(file));
return f;
}
public static void main(String[] args) throws Exception {
try (Playwright playwright = Playwright.create()) {
BrowserType.ConnectOptions auth = new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY));
// Session 1: "sign in", then save the state twice: the default way and with IndexedDB.
JsonObject s1 = launch();
Browser b1 = playwright.chromium().connect(s1.get("wsUrl").getAsString(), auth);
try {
BrowserContext ctx = b1.newContext();
Page page = ctx.newPage();
page.navigate(ORIGIN + "/cookies/set?session=abc123", new Page.NavigateOptions().setTimeout(60000));
page.evaluate("(" + WRITE + ")()");
ctx.storageState(new BrowserContext.StorageStateOptions().setPath(PLAIN));
ctx.storageState(new BrowserContext.StorageStateOptions().setPath(WITH_IDB).setIndexedDB(true));
} finally {
b1.close();
}
// Session 2: a new browser. Restore each file into its own context and look around.
JsonObject s2 = launch();
Browser b2 = playwright.chromium().connect(s2.get("wsUrl").getAsString(), auth);
try {
JsonArray restored = new JsonArray();
JsonElement valueTypes = JsonNull.INSTANCE;
Map<String, Path> files = new LinkedHashMap<>();
files.put("cookies + localStorage", PLAIN);
files.put("with IndexedDB", WITH_IDB);
for (Map.Entry<String, Path> f : files.entrySet()) {
BrowserContext ctx = b2.newContext(new Browser.NewContextOptions().setStorageStatePath(f.getValue()));
Page page = ctx.newPage();
boolean cookie = cookieSent(page);
JsonObject seen = GSON.toJsonTree(page.evaluate("(" + READ + ")()")).getAsJsonObject();
JsonObject r = new JsonObject();
r.addProperty("state", f.getKey());
r.addProperty("cookie_sent", cookie);
r.add("local_storage_theme", seen.get("local_storage_theme"));
r.add("firebase_user", seen.get("firebase_user"));
r.addProperty("signed_in", !seen.get("firebase_user").isJsonNull());
restored.add(r);
if (f.getValue().equals(WITH_IDB)) valueTypes = seen.get("value_types");
ctx.close();
}
JsonObject out = new JsonObject();
out.addProperty("origin", ORIGIN);
JsonArray sessions = new JsonArray();
sessions.add(s1.get("sessionId"));
sessions.add(s2.get("sessionId"));
out.add("sessions", sessions);
JsonArray stateFiles = new JsonArray();
stateFiles.add(stateFile("cookies + localStorage", PLAIN));
stateFiles.add(stateFile("with IndexedDB", WITH_IDB));
out.add("state_files", stateFiles);
out.add("restored", restored);
out.add("value_types_after_restore", valueTypes);
System.out.println(GSON.toJson(out));
} finally {
b2.close();
}
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
const string Origin = "https://httpbin.org";
// Both files hold a live login: keep them somewhere safe.
var plain = Path.Combine(Path.GetTempPath(), "cdpfleet-state.json");
var withIdb = Path.Combine(Path.GetTempPath(), "cdpfleet-state-idb.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 string 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 string 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 };
}
""";
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
async Task<JsonElement> Launch()
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
return await res.Content.ReadFromJsonAsync<JsonElement>();
}
async Task<bool> CookieSent(IPage page)
{
await page.GotoAsync($"{Origin}/cookies", new() { Timeout = 60000 });
var cookies = JsonNode.Parse(await page.TextContentAsync("body") ?? "{}")!["cookies"]!.AsObject();
return cookies["session"]?.GetValue<string>() == "abc123";
}
JsonArray IdbDatabases(string file)
{
var names = new JsonArray();
foreach (var o in JsonNode.Parse(File.ReadAllText(file))!["origins"]!.AsArray())
if (o!["indexedDB"] is JsonArray dbs)
foreach (var d in dbs) names.Add(d!["name"]!.GetValue<string>());
return names;
}
using var playwright = await Playwright.CreateAsync();
var auth = new BrowserTypeConnectOptions { Headers = new Dictionary<string, string> { ["x-api-key"] = key } };
// Session 1: "sign in", then save the state twice: the default way and with IndexedDB.
var s1 = await Launch();
var b1 = await playwright.Chromium.ConnectAsync(s1.GetProperty("wsUrl").GetString()!, auth);
try
{
var ctx = await b1.NewContextAsync();
var page = await ctx.NewPageAsync();
await page.GotoAsync($"{Origin}/cookies/set?session=abc123", new() { Timeout = 60000 });
await page.EvaluateAsync(Write);
await ctx.StorageStateAsync(new() { Path = plain });
await ctx.StorageStateAsync(new() { Path = withIdb, IndexedDB = true });
}
finally
{
await b1.CloseAsync();
}
// Session 2: a new browser. Restore each file into its own context and look around.
var s2 = await Launch();
var b2 = await playwright.Chromium.ConnectAsync(s2.GetProperty("wsUrl").GetString()!, auth);
try
{
var restored = new JsonArray();
JsonNode? valueTypes = null;
foreach (var (label, file) in new[] { ("cookies + localStorage", plain), ("with IndexedDB", withIdb) })
{
var ctx = await b2.NewContextAsync(new() { StorageStatePath = file });
var page = await ctx.NewPageAsync();
var cookie = await CookieSent(page);
var seen = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(Read)).GetRawText())!;
var user = seen["firebase_user"]?.GetValue<string>();
restored.Add(new JsonObject
{
["state"] = label,
["cookie_sent"] = cookie,
["local_storage_theme"] = seen["local_storage_theme"]?.GetValue<string>(),
["firebase_user"] = user,
["signed_in"] = user != null,
});
if (file == withIdb) valueTypes = seen["value_types"]?.DeepClone();
await ctx.CloseAsync();
}
var output = new JsonObject
{
["origin"] = Origin,
["sessions"] = new JsonArray(s1.GetProperty("sessionId").GetString(), s2.GetProperty("sessionId").GetString()),
["state_files"] = new JsonArray(
new JsonObject { ["state"] = "cookies + localStorage", ["bytes"] = new FileInfo(plain).Length, ["indexeddb_databases"] = IdbDatabases(plain) },
new JsonObject { ["state"] = "with IndexedDB", ["bytes"] = new FileInfo(withIdb).Length, ["indexeddb_databases"] = IdbDatabases(withIdb) }),
["restored"] = restored,
["value_types_after_restore"] = valueTypes,
};
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await b2.CloseAsync();
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"github.com/playwright-community/playwright-go"
)
const origin = "https://httpbin.org"
var (
key = os.Getenv("CDPFLEET_API_KEY")
// Both files hold a live login: keep them somewhere safe.
plain = filepath.Join(os.TempDir(), "cdpfleet-state.json")
withIdb = filepath.Join(os.TempDir(), "cdpfleet-state-idb.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 };
}`
func launch() (map[string]any, error) {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true})
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chromium/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch: %s %s", res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
type stateFile struct {
Origins []struct {
Origin string `json:"origin"`
IndexedDB []map[string]any `json:"indexedDB"`
} `json:"origins"`
}
func readState(file string) stateFile {
var s stateFile
data, err := os.ReadFile(file)
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &s); err != nil {
log.Fatal(err)
}
return s
}
func idbDatabases(file string) []string {
names := []string{}
for _, o := range readState(file).Origins {
for _, d := range o.IndexedDB {
names = append(names, d["name"].(string))
}
}
return names
}
func cookieSent(page playwright.Page) bool {
res, err := page.Goto(origin+"/cookies", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err != nil {
log.Fatal(err)
}
var body struct {
Cookies map[string]string `json:"cookies"`
}
if err := res.JSON(&body); err != nil {
log.Fatal(err)
}
return body.Cookies["session"] == "abc123"
}
type restoredState struct {
State string `json:"state"`
CookieSent bool `json:"cookie_sent"`
LocalStorageTheme any `json:"local_storage_theme"`
FirebaseUser any `json:"firebase_user"`
SignedIn bool `json:"signed_in"`
}
type fileInfo struct {
State string `json:"state"`
Bytes int64 `json:"bytes"`
IndexedDBDatabases []string `json:"indexeddb_databases"`
}
func size(file string) int64 {
st, err := os.Stat(file)
if err != nil {
log.Fatal(err)
}
return st.Size()
}
// Session 1: set a cookie, write the Firebase-shaped login, save both state files.
func signIn(pw *playwright.Playwright, auth playwright.BrowserTypeConnectOptions) map[string]any {
s1, err := launch()
if err != nil {
log.Fatal(err)
}
b1, err := pw.Chromium.Connect(s1["wsUrl"].(string), auth)
if err != nil {
log.Fatal(err)
}
// log.Panic below (not Fatal) so this deferred Close still ends the fleet session.
defer b1.Close()
ctx, err := b1.NewContext()
if err != nil {
log.Panic(err)
}
page, err := ctx.NewPage()
if err != nil {
log.Panic(err)
}
if _, err := page.Goto(origin+"/cookies/set?session=abc123", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
log.Panic(err)
}
if _, err := page.Evaluate(write); err != nil {
log.Panic(err)
}
if _, err := ctx.StorageState(playwright.BrowserContextStorageStateOptions{Path: playwright.String(plain)}); err != nil {
log.Panic(err)
}
if _, err := ctx.StorageState(playwright.BrowserContextStorageStateOptions{Path: playwright.String(withIdb), IndexedDB: playwright.Bool(true)}); err != nil {
log.Panic(err)
}
return s1
}
func main() {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
auth := playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}
// Session 1: "sign in", then save the state twice: the default way and with IndexedDB.
s1 := signIn(pw, auth)
// Session 2: a new browser. Restore each file into its own context and look around.
s2, err := launch()
if err != nil {
log.Fatal(err)
}
b2, err := pw.Chromium.Connect(s2["wsUrl"].(string), auth)
if err != nil {
log.Fatal(err)
}
defer b2.Close()
restored := []restoredState{}
var valueTypes any
for _, f := range []struct{ label, file string }{{"cookies + localStorage", plain}, {"with IndexedDB", withIdb}} {
// Not NewContext's StorageStatePath: playwright-go parses that file into a typed
// struct with no IndexedDB field and silently drops it. SetStorageState passes the
// file to Playwright untouched.
ctx, err := b2.NewContext()
if err != nil {
log.Fatal(err)
}
if err := ctx.SetStorageState(f.file); err != nil {
log.Fatal(err)
}
page, err := ctx.NewPage()
if err != nil {
log.Fatal(err)
}
cookie := cookieSent(page)
v, err := page.Evaluate(read)
if err != nil {
log.Fatal(err)
}
seen := v.(map[string]any)
restored = append(restored, restoredState{
State: f.label, CookieSent: cookie, LocalStorageTheme: seen["local_storage_theme"],
FirebaseUser: seen["firebase_user"], SignedIn: seen["firebase_user"] != nil,
})
if f.file == withIdb {
valueTypes = seen["value_types"]
}
ctx.Close()
}
out, _ := json.MarshalIndent(map[string]any{
"origin": origin,
"sessions": []any{s1["sessionId"], s2["sessionId"]},
"state_files": []fileInfo{
{"cookies + localStorage", size(plain), idbDatabases(plain)},
{"with IndexedDB", size(withIdb), idbDatabases(withIdb)},
},
"restored": restored,
"value_types_after_restore": valueTypes,
}, "", " ")
fmt.Println(string(out))
}
What we got
| State file | File size (bytes) | Cookie sent | localStorage | Firebase user in IndexedDB | App signed in |
|---|---|---|---|---|---|
| cookies + localStorage | 403 | yes | dark | — | no |
| with IndexedDB | 2551 | yes | dark | [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
- The default file brought back the cookie and localStorage but not the user — a Firebase app would show its sign-in form. With
indexedDB: truethe user and its tokens came back too, so the app would be signed in. - Only saving needs the option.
newContext({ storageState })restores whatever IndexedDB is in the file. The file grew from a few hundred bytes to about 1–2.5 KB here (Node writes it indented) — and it now holds the refresh token in plain text, so guard it like a password. - Not every value survives:
Date,Uint8ArrayandBigIntcame back as themselves; aMapand aBlobcame back as empty objects{}. Check what the app you depend on stores before trusting a restored login. - In Go, restore with
SetStorageState: playwright-go 0.6000'sStorageStatePathonNewContextdrops IndexedDB (it parses the file into a typed struct with no field for it — tried here, the app came back signed out).context.SetStorageState(path)passes the file to Playwright untouched, IndexedDB included. The other four clients restore it fromnewContextas is.