BrowserGateway

Profiles

Persist browser state across sessions, cookies, localStorage, and on the self-hosted runtime IndexedDB and service workers, encrypted at rest with a key you control.

Profiles persist browser state across sessions, cookies, localStorage, and on the self-hosted runtime IndexedDB and service workers, encrypted at rest with a key you control.

When To Use Them

Anything where you'd otherwise log in again every run:

  • Scraping behind a login wall. Log in once, run for weeks.
  • AI agents that crash mid-task, resume cleanly without redoing the auth flow.
  • Automated checkout testing, start from "already signed in" state.
  • Multi-account workflows, one profile per account, no cross-contamination.

How It Works

Connect with ?profile=acme-prod on the WebSocket URL. Before the browser is handed back, the gateway loads that profile's saved state into it, so the first page is already signed in. When the session disconnects, the gateway saves the updated state back, encrypted. That's the whole loop: load on connect, save on disconnect.

import puppeteer from "puppeteer-core";

const browser = await puppeteer.connect({
  browserWSEndpoint: "ws://localhost:9500/v1/connect?profile=acme-prod&token=YOUR_BG_TOKEN",
});
const page = await browser.newPage();
await page.goto("https://your-app.example.com/dashboard"); // already authenticated
await browser.disconnect();

What a profile contains

  • Cookies, every origin, including HttpOnly, with all security attributes (Secure, SameSite, partitioning) preserved so a protected cookie is never silently downgraded. Works on every provider.
  • localStorage, per origin. On the self-hosted runtime, read directly from disk so every origin is captured. On remote providers, the DevTools protocol has no way to list which origins hold localStorage, so capture covers the origins the gateway discovers (any origin that set a cookie, plus any already stored in the profile).
  • IndexedDB (full record contents) and service worker registrations, self-hosted runtime only. The DevTools protocol cannot move these across the wire, so no remote provider can carry them. The runtime moves them as files because it owns the browser's disk. Many apps (Firebase, Supabase, and similar) keep their session in IndexedDB, not cookies, so those profiles only fully round-trip on the runtime.

Not captured: sessionStorage (tab-scoped, replaying stale values breaks OAuth redirects and CSRF flows), browser history, downloads, bookmarks, open tabs, or in-memory JavaScript state.

Read-Only vs Write-Back

By default, ?profile=X opts into write-back. Cookies + storage load in at connect; on disconnect the updated state is captured and saved back. This is the model you want for "log in once, resume tomorrow" workflows.

Add &readOnly=1 to switch to a read-only session. State loads in exactly the same way, but nothing writes back on disconnect. Read-only sessions:

  • Take no lock, so an unlimited number of read-only sessions can share the same profile at once, across any number of gateways or providers.
  • Are faster (no capture step at the end).
  • Bypass the provider eligibility gate below, because nothing is ever written back, so cross-profile leak on a shared upstream is physically impossible.
const browser = await puppeteer.connect({
  browserWSEndpoint:
    "ws://localhost:9500/v1/connect?profile=acme-prod&readOnly=1&token=YOUR_BG_TOKEN",
});

The pattern: build the profile once with a normal (write-back) session (log in), then fan out as many read-only sessions on it as you need. It works on any healthy CDP provider.

Providers And Profiles

Any provider can load and save any profile. A provider set to one profile takes only that profile's sessions. A provider with no profile setting takes every session, with or without a profile.

OSS gateway (gateway.yml):

providers:
  cloud:
    url: wss://provider.example.com?token=${TOKEN}
    # no profile setting: takes every session and saves any profile

  provider-team-acme:
    url: wss://provider.example.com?token=${TOKEN_A}
    profile: team-acme        # only ?profile=team-acme sessions come here

Dashboard: the provider form's "Which sessions it serves" has two choices, Any session or Only <profile>.

multiProfile: true is accepted and means the same as no profile setting.

What gets saved from a reused browser

At the end of a session the gateway saves the cookies and site storage it finds in the browser. Most cloud providers give every connection a fresh browser, so the profile holds exactly what your session did.

Some providers, and a self-hosted Chrome kept running between sessions, reuse the same browser. There, anything an earlier session left behind is saved into your profile too, for example a login from a session that used no profile. The one thing the gateway refuses is a browser that still holds a different profile (see Provider Residue Detection).

If a profile must contain only what its own sessions did, use a provider that starts a fresh browser for every connection, such as the self-hosted runtime (browserserve).

Selection rules

  • ?profile=X routes to providers with no profile setting, providers set to X, and the self-hosted runtime.
  • ?profile=X when every provider is set to a different profile returns HTTP 400.
  • Connections with no ?profile= route to providers with no profile setting.
  • A provider that refuses a connection with HTTP 409 (it holds a different profile) is skipped for that connection only. It is not marked as failing.

Provider Residue Detection

A reused browser can still hold the previous profile. The gateway checks for this before loading the next profile and refuses the connection instead of mixing two profiles.

At profile-inject time, the gateway plants two independent sentinels on the upstream:

  • Cookie sentinel on synthetic domain __bg-marker.internal. Catches providers that reuse the same browser context between sessions.
  • localStorage sentinel on synthetic origin https://__bg-marker.internal. Catches the Chromium 754576 leak where localStorage entries survive Target.disposeBrowserContext and reappear in the next context, which cookie-only detection misses.

Before every subsequent inject, both sentinels are probed. If either holds a different profile's marker, the connect is rejected with HTTP 409:

{
  "error": "provider_holds_different_profile",
  "providerId": "cloud-primary",
  "currentProfile": "acme",
  "requestedProfile": "bravo",
  "hint": "This provider currently holds a different profile's state. Retry with a different provider, or wait for the browser instance to release."
}

Read-only sessions get the same rejection. Even though nothing writes back, injecting profile-B's state on top of profile-A's leftover cookies exposes A's session to B's caller.

The self-hosted runtime is exempt from the check because every session gets a fresh isolated Chrome by construction, so no marker ever survives between sessions.

What to do when you see a 409: retry later or on another provider, or set a provider to that profile so the profile always gets its own browser (see Providers And Profiles).

Marker cookies and marker localStorage are filtered out of every captured profile blob, so they never persist into a user profile.

Concurrent Same-Profile Writes

Only one session can hold a profile's write lock at a time. If a second session opens the same profile with write-back while the first is still active, the second waits up to 15 seconds for the first to release. If the lock frees up in that time, the second session proceeds. If the wait runs out, the connect is rejected with HTTP 409:

HTTP/1.1 409 Conflict
Content-Type: application/json

{"error":"profile \"acme-prod\" is in use by another session"}

There is no Retry-After header. Retry once the first session has disconnected and its state has been saved.

Read-only sessions with ?readOnly=1 never contend for the lock, so many read-only sessions can share the same profile at once. See Read-Only vs Write-Back.

Quickstart. Enabling The Feature

Profiles are off by default. Enabling them is one click, the encryption key is auto-managed.

  1. Start the gateway: browser-gateway serve
  2. Open the dashboard at http://localhost:9500/web
  3. Click Profiles → Enable Profiles
  4. Restart the gateway

The wizard appends a profiles: block to gateway.yml. On the next boot the gateway generates a 256-bit key, writes it to $BG_DATA_DIR/.encryption-key (mode 0600), and is ready.

Option B. Manual gateway.yml

# gateway.yml
profiles:
  enabled: true
  filesystem:
    path: ./profiles            # resolved relative to $BG_DATA_DIR
  encryption:
    keyEnv: BG_ENCRYPTION_KEY   # env var name (optional, auto file beats it absent)
browser-gateway serve

The encryption key resolution chain:

  1. BG_ENCRYPTION_KEY env var (set this for centralized secrets management, Vault, AWS Secrets Manager, Doppler)
  2. $BG_DATA_DIR/.encryption-key file (auto-managed, persists across restarts)
  3. Generate fresh on first boot, write to the file

Most operators don't need to set anything, backups of $BG_DATA_DIR include the key.

Once enabled, any ?profile=<id> connection auto-creates a profile on first disconnect.

Serving Multiple Profiles

One provider serves every profile. Set profile: on a provider only when you want a profile's sessions to land on that provider, for example one account per provider.

browserserve, the self-hosted runtime, gives every connection a fresh browser, so each profile holds only what its own sessions did, including IndexedDB and service workers. It is auto-detected.

providers:
  runtime:
    url: ws://browserserve:9222/   # serves every profile

One Writer At A Time

A profile is edited by one write-back session at a time. When a session is using acme-prod, another write-back request for the same profile waits up to 15 seconds for it to finish. If it finishes in time, the new request proceeds. If not, the new request gets HTTP 409 (see Concurrent Same-Profile Writes). Different profiles run fully in parallel, so run as many different profiles at once as you like.

This keeps two writers from overwriting each other. It's enforced within a single gateway (even when that gateway routes to several browser instances). If multiple gateways share a profile store, don't drive the same profile as a writer from two of them at once, cross-gateway locking isn't coordinated yet. For high-concurrency reads on the same profile, use ?readOnly=1 (see Read-Only vs Write-Back).

What Each Profile Id Does

ActionWhat happens
First connect with ?profile=acmeRoutes to any provider not set to a different profile. Normal blank session. Disconnect captures a new blob.
Subsequent connect with ?profile=acmeGateway decrypts the blob and replays cookies + storage before you start.
Connect with ?profile=acme&readOnly=1Loads the profile but never saves it back. No lock. Many read-only sessions can share it at once.
Concurrent write-back connects with ?profile=acmeThe first uses it. A second waits up to 15s for it to finish. It proceeds if the first finishes in time, otherwise it gets HTTP 409.
Concurrent connects with different profile idsRun fully in parallel. No contention.
Connect with ?profile=X when every provider is set to a different profileHTTP 400. No provider serves profile 'X': every enabled provider is set to a different profile.
Connect with ?profile= and the provider holds a different profile's residueHTTP 409 provider_holds_different_profile (see Provider Residue Detection).
Connect with no ?profile=Routes to providers with no profile setting. No persistence.

Security Model

  • Encryption. AES-256-GCM with envelope encryption, every profile has a unique 256-bit Data Encryption Key (DEK) wrapped with the global Key Encryption Key (KEK) derived from BG_ENCRYPTION_KEY via scrypt. The DEK rotates per profile, the KEK rotates when you change the env var.
  • Anti-swap. The profile id is bound into the encryption (Additional Authenticated Data). Renaming the blob on disk makes it undecryptable, by design. This prevents an attacker from substituting one profile for another even with file system access.
  • Key check. Each profile carries a Key Check Value (KCV) that's verified before decryption. If you set the wrong BG_ENCRYPTION_KEY after rotating, the gateway reports "wrong key for profile X" instead of silently producing garbage.
  • Auth. The REST endpoints under /v1/profiles/* use the same BG_TOKEN auth as every other route. The dashboard uses an HttpOnly cookie signed against the token; it lasts up to 30 days and ends when the gateway restarts or anyone logs out.
  • What you must do. Store the encryption key like any other production secret. If you lose it, every stored profile becomes permanently unreadable, there is no recovery.

Profile Lifecycle

   client                gateway                disk
     │                      │                    │
     │  WS upgrade          │                    │
     │ ?profile=acme        │                    │
     │─────────────────────▶│                    │
     │                      │  read & decrypt    │
     │                      │  acme.bgp blob    ◀│
     │                      │                    │
     │                      │  inject cookies    │
     │                      │  + storage via CDP │
     │                      │                    │
     │  CDP traffic         │                    │
     │◀────────────────────▶│                    │
     │                      │                    │
     │  close               │                    │
     │─────────────────────▶│                    │
     │                      │  capture cookies   │
     │                      │  + storage via CDP │
     │                      │                    │
     │                      │  encrypt + write  ▶│
     │                      │  acme.bgp blob     │

Limitations & Edge Cases

No renaming yet

The id is the anti-swap binding, so a profile can't be renamed in place. To rename:

  1. Export old-name from the dashboard (downloads old-name.bgp)
  2. Import it under the new id
  3. Delete the old one

Encryption key rotation

Profiles are encrypted with a Data Encryption Key (DEK) that's itself wrapped with BG_ENCRYPTION_KEY. To rotate the master key:

  1. Decrypt and re-wrap each profile with the new key (a future browser-gateway profiles rekey command will automate this)
  2. Until then, decrypt-then-re-import is the manual path

Size cap

Profile blobs are uncompressed JSON wrapped in an encrypted envelope. Typical sizes:

  • Empty profile: about 600 bytes
  • 1 site with a few cookies: about 1 to 2 KB
  • Heavy IndexedDB (an email client's offline cache, for example): can reach 50 MB or more

The gateway streams capture/inject so memory pressure is bounded, but very large blobs slow down session startup. Keep one profile per logical account or site where possible.

REST API

EndpointMethodPurpose
/v1/profilesGETList profile metadata (no payloads)
/v1/profiles/:idGETSingle profile metadata
/v1/profiles/:idDELETEPermanent delete (refuses while locked)
/v1/profiles/createPOSTCreate an empty profile so it shows in the list before its first session. Body: {"id": "acme-prod"}. Returns 409 if the id exists.
/v1/profiles/:id/exportGETDownload the encrypted .bgp blob. Add ?format=playwright to download a Playwright storageState JSON instead (decrypted, so treat it as a secret).
/v1/profiles/importPOSTUpload a .bgp blob with Content-Type: application/octet-stream (id is taken from the blob's encryption AAD). Or send a Playwright storageState JSON with Content-Type: application/json and ?id=<profile-id>.
/v1/profiles/setupPOSTOne-click enable, appends a profiles block to gateway.yml. Encryption key is auto-managed under $BG_DATA_DIR/.encryption-key on the next boot. Available while profiles are off.
/v1/profiles/disablePOSTTurns profiles off in gateway.yml. Saved profiles stay on disk. Restart required.

Profile ids start with a letter or number and may contain letters, numbers, dots, dashes and underscores, up to 128 characters.

Moving profiles to and from Playwright

If you already have a Playwright storageState file (from context.storageState({ path })), import it as a profile:

curl -X POST "http://localhost:9500/v1/profiles/import?id=acme-prod" \
  -H "Authorization: Bearer $BG_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @storage-state.json

To go the other way, download a profile as storageState and pass it to browser.newContext({ storageState }):

curl -H "Authorization: Bearer $BG_TOKEN" \
  "http://localhost:9500/v1/profiles/acme-prod/export?format=playwright" -o storage-state.json

Playwright's storageState holds cookies and localStorage only, so IndexedDB does not carry across in either direction.

All endpoints use the gateway's standard BG_TOKEN auth.

Dashboard

The Profiles page in the dashboard at http://localhost:9500/web/profiles/:

  • Lists every profile with last-updated, size, and DEK version
  • + New Profile creates an empty profile and gives you a ?profile=<id> URL you can paste into your code
  • Import uploads a .bgp blob or a Playwright storageState JSON
  • Per-row Copy read-write and Copy read-only copy the full connect URL with the profile id baked in
  • Per-row Export downloads the encrypted blob for backup or transfer, or a Playwright storageState JSON
  • Per-row Delete removes the blob (refuses if a session currently holds the lock)
  • Enable Profiles wizard (shown when the feature is off) generates a key and applies the config
  • Disable turns the feature off in gateway.yml (profiles stay on disk, restart required)

Troubleshooting

HTTP 409 provider_holds_different_profile. The provider handed back a browser that still holds a different profile. Retry later or on another provider, or set a provider to profile: X so this profile always gets its own browser. See Provider Residue Detection.

HTTP 400 "No provider serves profile 'X'". Every enabled provider is set to a different profile. Remove the profile setting from one provider, set one to X, or add a runtime provider.

"Profile not found" on a connect that worked moments ago. The id is case-sensitive. acme-prod and Acme-Prod are different profiles.

409 profile "X" is in use by another session. Another session held the profile for longer than the 15s wait window. For many concurrent sessions on one profile, use ?readOnly=1. For concurrent writers, give each its own id.

"Wrong encryption key" / decryption fails. The BG_ENCRYPTION_KEY env var was changed. Either restore the old key or delete the affected blobs (they're unreadable without the original key).

Dashboard shows "Profiles API error: 503" or 401. The gateway can't reach the profile store. Check profiles.filesystem.path is writable and the gateway has been restarted since the feature was enabled.

Session restores cookies but the site still asks to log in. The site keeps its session in IndexedDB, not cookies. IndexedDB only round-trips on the self-hosted runtime. Route the profile through a runtime provider.

Roadmap

  • browser-gateway profiles rekey, encryption key rotation
  • Profile rename via export-import in a single dashboard action
  • Optional compression (zstd) to shrink large blobs
  • Per-profile retention policies (auto-delete after N days idle)
  • Coordinated (distributed) write-locking across multiple gateways

On this page