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 holdlocalStorage, 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 inIndexedDB, 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.
Provider Isolation
Profiles interact with providers in two very different ways. The gateway enforces the boundary because getting it wrong leaks one account's state into another account's session.
| Provider category | Write-back on ?profile=X | Multiple profiles on one provider |
|---|---|---|
| Self-hosted runtime (browserserve), each session isolated | Yes, any profile | Yes, concurrent (each session is a fresh isolated Chrome) |
| External CDP (any cloud CDP endpoint, self-hosted Chrome shared across sessions) | Yes, but only for the pinned profile | No, must pin one profile per provider slot with profile: "X" |
Why the split
The self-hosted runtime gives every connection its own fresh, isolated browser, so any profile is safe on it and it can serve many profiles concurrently.
External providers persist state on whatever browser they hand back. Running two profiles on the same underlying browser slot means profile-A's HttpOnly cookies, on-disk storage, localStorage, and service workers survive into profile-B's session. Undetectable from the gateway's side, no eviction possible. So external providers are supported only when each slot is pinned to exactly one profile:
OSS gateway (gateway.yml):
providers:
provider-team-acme:
url: wss://provider.example.com?token=${TOKEN_A}
profile: team-acme # this slot serves only ?profile=team-acme
provider-team-bravo:
url: wss://provider.example.com?token=${TOKEN_B}
profile: team-bravoCloud dashboard: on the Providers page, open the "Which profiles it serves" dropdown for each provider and pick the profile it serves. The options are:
- Sessions with no profile. The slot serves connections without
?profile=. - Any profile. Allowed only on the self-hosted runtime (browserserve). Selecting this on an external provider is rejected on save.
- Only
<profile>. The slot serves only that specific profile. This is the equivalent ofprofile: Xingateway.yml.
multiProfile: true on non-runtime providers is rejected at add-time and at config-load. The gateway returns a 400 from POST /v1/providers and logs a config error on boot. Only the self-hosted runtime is trusted to serve multiple profiles concurrently.
An unpinned external slot ("Sessions with no profile", or no profile: field) serves only stateless traffic (connections with no ?profile=).
Selection rules
?profile=Xroutes only to a slot pinned toX, or to the self-hosted runtime.?profile=Xwith no eligible provider returns HTTP 400 with a hint that names the missing pin.?profile=X&readOnly=1bypasses the eligibility gate and can route to any healthy CDP provider (see Read-Only vs Write-Back).- Connections with no
?profile=route only to unpinned slots.
Provider Residue Detection
Even a correctly pinned external provider can misbehave. Some providers advertise a "fresh browser per WS connect" model but reuse the underlying browser context or leak state through the Chromium session. The gateway now detects this before injecting the next session and fails closed.
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. localStoragesentinel on synthetic originhttps://__bg-marker.internal. Catches the Chromium 754576 leak wherelocalStorageentries surviveTarget.disposeBrowserContextand 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 is holding state from a different profile. Pin one profile per external provider slot with `profile: X` in gateway.yml, or switch to the self-hosted runtime for multi-profile use."
}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: pin one profile per external provider slot with profile: X (see Provider Isolation above), or route profile traffic to the self-hosted runtime.
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 wait runs out, the request is rejected with HTTP 409 and a Retry-After header.
HTTP/1.1 409 Conflict
Retry-After: 15
Content-Type: text/plain
Profile is in use by another sessionThe Retry-After value is the maximum lock wait budget in seconds. Retry after that time to make progress.
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.
Option A. Dashboard (recommended)
- Start the gateway:
browser-gateway serve - Open the dashboard at
http://localhost:9500/web - Click Profiles → Enable Profiles
- 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 serveThe encryption key resolution chain:
BG_ENCRYPTION_KEYenv var (set this for centralized secrets management, Vault, AWS Secrets Manager, Doppler)$BG_DATA_DIR/.encryption-keyfile (auto-managed, persists across restarts)- 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
Two models, depending on the provider category. Both patterns follow the provider isolation matrix.
Self-hosted runtime, one provider serves every profile (recommended)
browserserve, the self-hosted runtime, gives every connection its own fresh, isolated browser. A single browserserve provider serves any number of profiles with no pinning and no crosstalk. Point the gateway at it and any ?profile=<id> routes to it, it's auto-detected.
providers:
runtime:
url: ws://browserserve:9222/ # serves every profile, no pin neededExternal providers, one profile per pinned slot
Pin each slot to one profile with profile:, and add one slot per profile.
providers:
provider-team-acme:
url: wss://provider.example.com?token=${TOKEN_A}
profile: team-acme # this slot serves only ?profile=team-acme
provider-team-bravo:
url: wss://provider.example.com?token=${TOKEN_B}
profile: team-bravoStartup validation: with profiles.enabled: true and neither a runtime provider nor a pinned slot, the gateway logs a warning that no provider can serve a 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 for it to finish (up to about 15 seconds) and then proceeds, it does not fail. 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
| Action | What happens |
|---|---|
First connect with ?profile=acme | Routes to a slot pinned to acme or to the runtime. Normal blank session. Disconnect captures a new blob. |
Subsequent connect with ?profile=acme | Gateway decrypts the blob and replays cookies + storage before you start. |
Connect with ?profile=acme&readOnly=1 | Loads the profile but never saves it back. No lock. Many read-only sessions can share it at once. |
Concurrent write-back connects with ?profile=acme | The first uses it. A second waits up to about 15s for it to finish, then proceeds. |
| Concurrent connects with different profile ids | Run fully in parallel. No contention. |
Connect with ?profile= matching no pinned slot | HTTP 400. No provider is configured to serve profile 'X'. Pin a provider slot with 'profile: X'. |
Connect with ?profile= and the provider holds a different profile's residue | HTTP 409 provider_holds_different_profile (see Provider Residue Detection). |
Connect with no ?profile= | Routes only to unpinned slots. 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_KEYvia 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_KEYafter rotating, the gateway reports "wrong key for profile X" instead of silently producing garbage. - Auth. The REST endpoints under
/v1/profiles/*use the sameBG_TOKENauth as every other route. The dashboard uses an HttpOnly cookie signed against the token. - 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:
- Export
old-namefrom the dashboard (downloadsold-name.bgp) - Import it under the new id
- 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:
- Decrypt and re-wrap each profile with the new key (a future
browser-gateway profiles rekeycommand will automate this) - 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
| Endpoint | Method | Purpose |
|---|---|---|
/v1/profiles | GET | List profile metadata (no payloads) |
/v1/profiles/:id | GET | Single profile metadata |
/v1/profiles/:id | DELETE | Permanent delete (refuses while locked) |
/v1/profiles/:id/export | GET | Download the encrypted .bgp blob |
/v1/profiles/import | POST | Upload a .bgp blob (id is taken from the blob's encryption AAD) |
/v1/profiles/setup | POST | One-click enable, appends profiles block to gateway.yml. Encryption key is auto-managed under $BG_DATA_DIR/.encryption-key on the next boot. |
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 generates a
?profile=<id>URL you can paste into your code - Per-row Copy WS URL copies the full connect URL with the profile id baked in
- Per-row Export downloads the encrypted blob for backup or transfer
- 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
- Recent Replays appears on the per-profile detail page when session replay is enabled
Troubleshooting
HTTP 409 provider_holds_different_profile.
The external provider still has residue from a previous profile's session. Pin one profile per slot with profile: X in gateway.yml, or route profile traffic to the self-hosted runtime. See Provider Residue Detection.
HTTP 400 "No provider is configured to serve profile 'X'".
No provider slot is pinned to X and no runtime provider is configured. Add profile: X to an external slot, add a runtime provider, or add &readOnly=1 to the connect URL.
"Profile not found" on a connect that worked moments ago.
The id is case-sensitive. acme-prod and Acme-Prod are different profiles.
409 with LOCK_HELD.
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
browserserve
browserserve is a self-hosted browser server. One container runs isolated Chrome sessions over CDP, with an optional profile channel to save and restore session state. Run it standalone, or add it to the gateway as a provider.
Session Lifecycle
Understanding what happens when connections open and close through the gateway.