Authenticated Profiles
Authenticated profiles let you log in to a website once and reuse that signed-in state across many parallel browser sessions. Once the profile is captured, any session that passes parameter ?profile=<profile-name> automatically restores the cookies, localStorage, and IndexedDB entries without requiring you to log in again.
This is useful when:
- Your scraper or agent needs to act as a logged-in user, but you don't want to re-authenticate on every run.
- You want to share an authenticated state across a fleet of workers without distributing credentials.
- You're running multi-step flows (paginated dashboards, account-only endpoints) where logging in each time is the slowest part.
- A Browserless account (Prototyping plan or above)
- A CDP client like Puppeteer or Playwright installed
How it works
A profile captures three pieces of the browser state from a live session and stores them under a name scoped to your API token:
- Cookies — including HttpOnly, Secure, and SameSite attributes
localStorage— for every origin the session touchedIndexedDB— databases, object stores, and entries
sessionStorage not stored?sessionStorage is excluded because its values only exist for the duration of a single browser tab. Restoring them in a new session would inject stale data that breaks OAuth redirects, CSRF tokens, and other short-lived flows.
Each time you pass ?profile=<profile-name>, Browserless restores the saved profile state before your code runs. Any changes during the session stays local to that session and won't affect the original profile.
Creating a profile
Creation needs a CDP-capable client (Puppeteer, Playwright, or raw CDP) because Browserless.saveProfile is a CDP method, not a BrowserQL mutation. Once a profile exists, BrowserQL can use it freely via ?profile=<profile-name>.
Start a creation browser session
Call
POST /profilewith a name for the profile:curl -X POST "https://production-sfo.browserless.io/profile?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{ "name": "acme-prod" }'The response contains the WebSocket URL under the
connectkey:{
"id": "<temporary-session-id>",
"name": "acme-prod",
"connect": "wss://production-sfo.browserless.io/session/connect/<id>?token=...",
"stop": "https://production-sfo.browserless.io/session/<id>?token=..."
}id— Unique identifier for the temporary creation sessionname— The profile nameconnect— WebSocket URL, this will be passed asbrowserWSEndpointin Puppeteer or Playwrightstop— callDELETEto this URL to terminate the creation session early
Connect and authenticate
Use the
connectURL returned from the previous step to attach Puppeteer to the running browser, then navigate to your site and complete the login:import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({ browserWSEndpoint: session.connect });
const page = await browser.newPage();
await page.goto('https://app.example.com/login');
await page.type('#email', 'me@example.com');
await page.type('#password', process.env.PASSWORD);
await page.click('button[type="submit"]');
await page.waitForNavigation();Dealing with CAPTCHAs or MFA?If the site uses CAPTCHAs, MFA, or magic links, use a live session link to open the browser and complete the login manually instead.
Save the captured state
Once the browser holds the authenticated state, send the
Browserless.saveProfileCDP command. Browserless captures the cookies,localStorage, andIndexedDBfrom the running browser and persists them under the profile name.const cdp = await page.createCDPSession();
const result = await cdp.send('Browserless.saveProfile', { name: 'acme-prod' });
console.log(result);
// { ok: true, profileId: '<id>', name: 'acme-prod', cookieCount: 12, originCount: 1 }
await browser.close();The
nameyou pass toBrowserless.saveProfileis what gets stored. That's what you'll reference as?profile=<profile-name>in future sessions. Thenamein thePOST /profilerequest is just a label for the session and doesn't have to match.Profile exceeds the limit?If the profile is too large to save, the call returns
{ ok: false, error: ... }and nothing is stored. See Captured state is too large for how to fix it.
Your authenticated profile is now stored. Pass ?profile=<profile-name> on any session to start already logged in with no credentials required.
Complete end-to-end example
The full flow in a single script. This code creates a session, opens a live session link for manual login, saves the authenticated state, then reconnects with the saved profile.
Save a profile (with human-assisted login):
import puppeteer from 'puppeteer-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const ORIGIN = 'https://production-sfo.browserless.io';
// 1. Launch a creation session with a generous timeout.
const session = await fetch(`${ORIGIN}/profile?token=${TOKEN}&timeout=600000`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'acme-prod' }),
}).then((r) => r.json());
// 2. Connect and navigate to the login page.
const browser = await puppeteer.connect({ browserWSEndpoint: session.connect });
const page = await browser.newPage();
await page.goto('https://app.example.com/login');
// 3. Open a live session link so a human can complete the login (2FA, CAPTCHA, etc.).
const cdp = await page.createCDPSession();
const { liveURL } = await cdp.send('Browserless.liveURL', { timeout: 600000 });
console.log('Complete the login here:', liveURL);
// 4. Wait for the user to finish and close the live view.
await new Promise((r) => cdp.on('Browserless.liveComplete', r));
// 5. Verify the login landed on the expected page.
await page.waitForFunction(
() => window.location.href.includes('dashboard'),
{ timeout: 30_000 }
);
// 6. Save the authenticated state.
const result = await cdp.send('Browserless.saveProfile', { name: 'acme-prod' });
console.log(result);
// { ok: true, profileId: '<id>', name: 'acme-prod', cookieCount: 12, originCount: 1 }
await browser.close();
Puppeteer does not have a page.waitForURL() method — that API is Playwright-only. Use page.waitForFunction instead to wait for URL changes:
await page.waitForFunction(
() => window.location.href.includes('example.com/dashboard'),
{ timeout: 60_000 }
);
Reuse the saved profile:
import puppeteer from 'puppeteer-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const browser = await puppeteer.connect({
browserWSEndpoint: `wss://production-sfo.browserless.io?token=${TOKEN}&profile=acme-prod`,
});
const page = await browser.newPage();
await page.goto('https://app.example.com/dashboard'); // already logged in
await browser.close();
Using a profile
Once a profile exists, pass ?profile=<profile-name> on any browser-launching request. The examples below show how to connect to Browserless with a saved profile across different clients and request types:
- Puppeteer
- Playwright
- Python
- BrowserQL
- REST APIs
- Persisted Session
import puppeteer from 'puppeteer-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const browserWSEndpoint = `wss://production-sfo.browserless.io?token=${TOKEN}&profile=acme-prod`;
const browser = await puppeteer.connect({ browserWSEndpoint });
const page = await browser.newPage();
await page.goto('https://app.example.com/dashboard'); // already logged in
import { chromium } from 'playwright-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const browserWSEndpoint = `wss://production-sfo.browserless.io/chromium/playwright?token=${TOKEN}&profile=acme-prod`;
const browser = await chromium.connect(browserWSEndpoint);
const page = await browser.newPage();
await page.goto('https://app.example.com/dashboard');
from playwright.sync_api import sync_playwright
TOKEN = "YOUR_API_TOKEN_HERE"
WS_ENDPOINT = (
f"wss://production-sfo.browserless.io/chromium/playwright?token={TOKEN}&profile=acme-prod"
)
with sync_playwright() as playwright:
browser = playwright.chromium.connect_over_cdp(WS_ENDPOINT)
context = browser.contexts[0]
page = context.pages[0]
page.goto("https://app.example.com/dashboard")
browser.close()
curl -X POST "https://production-sfo.browserless.io/chromium/bql?token=YOUR_API_TOKEN_HERE&profile=acme-prod" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { goto(url: \"https://app.example.com/dashboard\") { status } screenshot { base64 } }"
}'
?profile= works on the REST endpoints that launch a fresh browser per request — /screenshot, /pdf, /unblock, and /chromium/export (plus their /chrome/* variants). The captured state is loaded into the browser before the page is rendered, so you can take a screenshot or PDF of an authenticated page without scripting the login.
curl -X POST "https://production-sfo.browserless.io/screenshot?token=YOUR_API_TOKEN_HERE&profile=acme-prod" \
-H "Content-Type: application/json" \
-d '{ "url": "https://app.example.com/dashboard" }' \
--output dashboard.png
See Endpoint coverage for the full list of supported endpoints and the handful of exceptions.
A profile can also be attached to a persisted session. Pass profile in the POST /session body, and the captured state is loaded the first time the session's browser starts.
import puppeteer from 'puppeteer-core';
const TOKEN = 'YOUR_API_TOKEN_HERE';
const ORIGIN = 'https://production-sfo.browserless.io';
// 1. Create a persisted session pre-loaded with a saved profile.
const session = await fetch(`${ORIGIN}/session?token=${TOKEN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ttl: 1_800_000, // session lifetime in ms (30 minutes here)
profile: 'acme-prod', // saved auth state, loaded on first browser launch
}),
}).then((r) => r.json());
// 2. Connect to the session — the browser is already authenticated.
const browser = await puppeteer.connect({ browserWSEndpoint: session.connect });
const page = await browser.newPage();
await page.goto('https://app.example.com/dashboard');
// 3. Disconnect when done — the browser stays alive for the remaining TTL.
await browser.disconnect();
// 4. Later, reconnect with the same `session.connect` URL — same browser,
// same cookies/storage you accumulated since the profile was first loaded.
The profile is loaded once when the session's browser first starts. After that, any state changes (new cookies set, localStorage writes) accumulate on the live browser and are not written back to the saved profile.
Profile names are scoped to your token. Different tokens can't see or use each other's profiles.
Managing profiles
Profiles are managed through the following REST endpoints. All endpoints that return data use the same profile metadata object:
{
"id": "string",
"name": "string",
"cookieCount": "number",
"originCount": "number",
"lastUsedAt": "string | null",
"createdAt": "string",
"updatedAt": "string"
}
List profiles
curl "https://production-sfo.browserless.io/profiles?token=YOUR_API_TOKEN_HERE"
Returns an array of profile metadata objects. Paginate via limit (default 100, capped at 1000) and offset (default 0).
Get one profile
curl "https://production-sfo.browserless.io/profile/acme-prod?token=YOUR_API_TOKEN_HERE"
Returns a single profile metadata object.
Rename
curl -X PUT "https://production-sfo.browserless.io/profile/acme-prod?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{ "name": "acme-staging" }'
Returns the updated profile metadata object with the new name.
Delete
curl -X DELETE "https://production-sfo.browserless.io/profile/acme-prod?token=YOUR_API_TOKEN_HERE"
Returns a 204 No Content on success. Deletion is permanent and both the profile and its captured state are removed.
Manage profile state via JSON
If you already have authentication state from another system, you can create or refresh a profile directly from JSON. These endpoints do not launch a browser; Browserless stores the supplied cookies, localStorage, and IndexedDB state directly.
The state payload must use the Browserless profile format:
cookiesis an array of browser cookies. Each cookie needsname,value, anddomain; Browserless fills defaults for optional fields such aspath,expires,httpOnly,secure, andsession.originsis an array of origin-scoped storage objects. Eachoriginmust be anhttporhttpsorigin, andlocalStoragemust be an object whose keys and values are strings.indexedDBsis optional. When present, each database needsname,version, andobjectStores; each object store needsname,keyPath,autoIncrement,entries, andindexes.
Playwright's storageState() returns origins[].localStorage as an array of { name, value } pairs. Convert it to an object before uploading:
const toBrowserlessState = (storageState) => ({
cookies: storageState.cookies,
origins: storageState.origins.map(({ origin, localStorage }) => ({
origin,
localStorage: Object.fromEntries(
(localStorage ?? []).map(({ name, value }) => [name, value]),
),
})),
});
If you include IndexedDB data from your own capture pipeline, send it as indexedDBs in the Browserless format shown above.
Upload a JSON profile
Use POST /profile/upload to create a new profile from a pre-captured state payload. The name must be unique for your API token. Uploading the same name twice returns an error; use POST /profile/refresh when you want to replace an existing profile's state.
curl -X POST "https://production-sfo.browserless.io/profile/upload?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"name": "acme-prod",
"state": {
"cookies": [
{
"name": "sid",
"value": "abc123",
"domain": ".example.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"session": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://app.example.com",
"localStorage": {
"authToken": "..."
},
"indexedDBs": [
{
"name": "auth-db",
"version": 1,
"objectStores": [
{
"name": "tokens",
"keyPath": null,
"autoIncrement": false,
"entries": [
{ "key": "refresh", "value": { "token": "..." } }
],
"indexes": []
}
]
}
]
}
]
}
}'
A successful upload returns the stored profile metadata plus a diagnostics object that reports anything Browserless dropped or truncated before saving:
{
"id": "profile_abc123",
"name": "acme-prod",
"cookieCount": 1,
"originCount": 1,
"lastUsedAt": null,
"createdAt": "2026-05-22T12:00:00.000Z",
"updatedAt": "2026-05-22T12:00:00.000Z",
"diagnostics": {
"skippedMalformedCookies": 0,
"skippedPrivateCookies": 0,
"skippedMalformedOrigins": 0,
"skippedPrivateOrigins": 0,
"truncatedOrigins": 0,
"skippedMalformedIdbDatabases": 0,
"truncatedIdbDatabases": 0,
"skippedMalformedIdbStores": 0,
"truncatedIdbEntries": 0
}
}
Refresh a JSON profile
Use POST /profile/refresh when an existing profile's cookies or storage have expired and you have a replacement state payload. The body is the same shape as /profile/upload, but the profile must already exist for the requesting token.
curl -X POST "https://production-sfo.browserless.io/profile/refresh?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"name": "acme-prod",
"state": {
"cookies": [
{
"name": "sid",
"value": "new-value",
"domain": ".example.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"session": true
}
],
"origins": [
{
"origin": "https://app.example.com",
"localStorage": {
"authToken": "new-token"
}
}
]
}
}'
Refresh overwrites the stored state in place and returns the same response shape as upload, including diagnostics. The profile keeps its id and name, while cookieCount, originCount, and updatedAt reflect the new state.
If the profile does not exist for the token, Browserless returns 404. Existing browser sessions that already loaded the old profile are not changed; new sessions that pass ?profile=acme-prod use the refreshed state.
Import from a local browser via the CLI
The @browserless.io/cli tool lets you capture cookies, localStorage, and IndexedDB directly from a local Chromium-based browser and upload them as a cloud profile — no scripting or JSON assembly required.
Install the CLI
npm install -g @browserless.io/cliRequires Node.js ≥ 24.
Authenticate
browserless auth login <your-token>The token is stored in your OS keychain when available and falls back to
~/.browserless/config.json.Upload a profile
browserless profile upload \
--browser chrome --profile Default --name my-chromeThe CLI reads the local browser's user-data directory, extracts the authentication state, and uploads it. Any Browserless session can now start already authenticated by passing
?profile=my-chrome.
The CLI supports Chrome, Edge, Brave, Chromium, and other Chromium-based browsers. Use browserless profile sources list to discover available local profiles.
Domain filtering
Sensitive domains can be excluded locally before upload — Browserless never sees them:
browserless profile upload \
--browser chrome --profile Default --name my-chrome \
--exclude-domain chase.com --only-domain github.com
The CLI filters domains locally before upload, so excluded data never leaves your machine. Browserless also applies server-side sanitization to strip private and internal cookies and origins as an additional safety net.
Use --auto-fit to automatically drop the heaviest origins when the captured state exceeds the 2 MB profile limit, and --keep-domain to protect specific domains from being dropped.
Refreshing an existing profile
To re-capture and overwrite an existing cloud profile:
browserless profile refresh \
--browser chrome --profile Default --name my-chrome
See the CLI README for the full command reference, including custom browser registration and configuration options.
Endpoint coverage
?profile=<profile-name> works on any request that launches a new Chromium-family browser. A few endpoints behave differently or don't support it at all which are listed below:
| Type | Endpoint | Status | Notes |
|---|---|---|---|
| WebSocket | /, /chromium, /chrome, /edge, /chromium/stealth, /chrome/stealth, /stealth | ✅ Supported | Auth state applied automatically |
| BrowserQL | /chromium/bql, /chrome/bql, /stealth/bql (POST and WS), /chromium/agent | ✅ Supported | Auth state applied automatically |
| REST | /screenshot, /pdf, /unblock, /chromium/export, /content, /scrape, /function, /download, /performance, /smart-scrape, /crawl (and /chrome/*, /chromium/*, /edge/* variants) | ✅ Supported | Auth state applied automatically |
| Session | POST /session with profile in the body | ✅ Supported | See the Persisted Session tab |
| Playwright | /chromium/playwright, /chrome/playwright, /edge/playwright | ⚠️ Caveat | Auth state is applied, but clients must re-use browser.contexts()[0]. New contexts start without cookies and storage. |
| Playwright | /firefox/playwright, /webkit/playwright | ❌ Not supported | Only Chromium-family browsers are supported |
| Reconnect | /reconnect/* | ❌ Not supported | ?profile= is rejected with a 400. The browser was already authenticated at first launch; changing its identity mid-session would invalidate the live state. |
For unsupported flows, capture and apply the auth state inside a BrowserQL or Puppeteer session instead.
FAQ
What are the limits for profiles?
These are the per-profile and per-session constraints. Hitting any of these will prevent a profile from being saved or cause a request to be rejected.
| Limit | Value |
|---|---|
| Captured state size | 2 MB |
| Distinct origins per profile | 50 |
| IndexedDB databases per origin | 5 |
| IndexedDB entries per object store | 1,000 |
| Profile name length | 255 characters |
| Profile name uniqueness | Per token |
| Creation session lifetime | 10 minutes |
| Unused-profile retention | 30 days from last use |
Profiles unused for 30 days are removed automatically.
When should I use a profile vs a persisted session?
Authenticated Profiles and Persisted Sessions solve different problems and can be combined:
- A profile is the what: the saved authentication state to load before your code runs.
- A persisted session is the how-long: a long-lived browser process you can disconnect from and reconnect to.
Use a profile when you want to reuse an authenticated state across many short-lived sessions, including in parallel. Each session gets its own working copy — workers don't fight over a single shared browser, and changes during one session don't leak into the others.
Use a persisted session when you need the same browser instance to survive across reconnects — for example, to keep tabs open or preserve in-flight UI state across a disconnect. Profiles snapshot auth and replay it into a fresh browser; persisted sessions keep the whole browser alive.
Use both together when you want a long-lived browser that starts with a saved auth state. Create the session with POST /session and profile: "<name>" in the body. The profile is loaded once when the browser starts; the session then persists normally.
Use neither for one-off automation where re-authenticating is cheap and you don't want to manage stored state.
Troubleshooting
Session times out before login completes
When using a live session link for manual login, there are two independent timeouts — both default to ~10 minutes, which may not be enough for SSO, 2FA, or magic-link flows.
| Timeout | Where to set it | What it controls |
|---|---|---|
| Session timeout | ?timeout= query param on POST /profile | How long the creation browser session stays alive |
| LiveURL timeout | { timeout: ms } param in Browserless.liveURL CDP call | How long the live session link stays open |
Set both explicitly when you need more time:
// Session timeout — set on the POST URL
const session = await fetch(`${ORIGIN}/profile?token=${TOKEN}&timeout=600000`, { ... });
// LiveURL timeout — set when creating the live link
const { liveURL } = await cdp.send('Browserless.liveURL', { timeout: 600000 });
Captured state is too large
The captured state (cookies, localStorage, and IndexedDB) is capped at 2 MB per profile. If your site stores large amounts of data in localStorage or IndexedDB, the save will fail with { ok: false, error: ... } and nothing is persisted.
To fix this, try:
- Clearing unused
localStoragekeys before saving the profile - Reducing the number of origins the session touches
- Checking the Limits FAQ entry for the full set of per-profile constraints
Further reading
- Persisting State — keep a single browser instance alive across reconnects
- Connection URL Patterns — full reference for connect-time query parameters
- BrowserQL — drive the creation session through the GraphQL layer
@browserless.io/cli— capture and upload profiles from your local browser via the command line