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.
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 hereDashboard: 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=Xroutes to providers with no profile setting, providers set toX, and the self-hosted runtime.?profile=Xwhen 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. 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 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.
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
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 profileOne 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
| Action | What happens |
|---|---|
First connect with ?profile=acme | Routes to any provider not set to a different profile. 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 15s for it to finish. It proceeds if the first finishes in time, otherwise it gets HTTP 409. |
| Concurrent connects with different profile ids | Run fully in parallel. No contention. |
Connect with ?profile=X when every provider is set to a different profile | HTTP 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 residue | HTTP 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_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; 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:
- 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/create | POST | Create 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/export | GET | Download the encrypted .bgp blob. Add ?format=playwright to download a Playwright storageState JSON instead (decrypted, so treat it as a secret). |
/v1/profiles/import | POST | Upload 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/setup | POST | One-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/disable | POST | Turns 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.jsonTo 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.jsonPlaywright'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
.bgpblob or a PlaywrightstorageStateJSON - 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
storageStateJSON - 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
Securing browserserve on cloud machines
Stop pages from reading your cloud machine's credentials, and run one isolated session per instance. Applies to browserserve on any cloud VM, container platform or Kubernetes cluster.
Session Lifecycle
Understanding what happens when connections open and close through the gateway.