For AI agents: a documentation index is available at /llms.txt
Skip to main content

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.
Prerequisites
  • 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 touched
  • IndexedDB — databases, object stores, and entries
Why is 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.

Sessions are isolated

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

CDP client required

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>.

  1. Start a creation browser session

    Call POST /profile with 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 connect key:

    {
    "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 session
    • name — The profile name
    • connect — WebSocket URL, this will be passed as browserWSEndpoint in Puppeteer or Playwright
    • stop — call DELETE to this URL to terminate the creation session early
  2. Connect and authenticate

    Use the connect URL 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.

  3. Save the captured state

    Once the browser holds the authenticated state, send the Browserless.saveProfile CDP command. Browserless captures the cookies, localStorage, and IndexedDB from 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 name you pass to Browserless.saveProfile is what gets stored. That's what you'll reference as ?profile=<profile-name> in future sessions. The name in the POST /profile request 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.

Profile is saved!

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();
Note for Playwright users

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:

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
tip

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:

  • cookies is an array of browser cookies. Each cookie needs name, value, and domain; Browserless fills defaults for optional fields such as path, expires, httpOnly, secure, and session.
  • origins is an array of origin-scoped storage objects. Each origin must be an http or https origin, and localStorage must be an object whose keys and values are strings.
  • indexedDBs is optional. When present, each database needs name, version, and objectStores; each object store needs name, keyPath, autoIncrement, entries, and indexes.
Adapting Playwright storage state

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.

  1. Install the CLI

    npm install -g @browserless.io/cli

    Requires Node.js ≥ 24.

  2. Authenticate

    browserless auth login <your-token>

    The token is stored in your OS keychain when available and falls back to ~/.browserless/config.json.

  3. Upload a profile

    browserless profile upload \
    --browser chrome --profile Default --name my-chrome

    The 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
info

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:

TypeEndpointStatusNotes
WebSocket/, /chromium, /chrome, /edge, /chromium/stealth, /chrome/stealth, /stealth✅ SupportedAuth state applied automatically
BrowserQL/chromium/bql, /chrome/bql, /stealth/bql (POST and WS), /chromium/agent✅ SupportedAuth state applied automatically
REST/screenshot, /pdf, /unblock, /chromium/export, /content, /scrape, /function, /download, /performance, /smart-scrape, /crawl (and /chrome/*, /chromium/*, /edge/* variants)✅ SupportedAuth state applied automatically
SessionPOST /session with profile in the body✅ SupportedSee the Persisted Session tab
Playwright/chromium/playwright, /chrome/playwright, /edge/playwright⚠️ CaveatAuth state is applied, but clients must re-use browser.contexts()[0]. New contexts start without cookies and storage.
Playwright/firefox/playwright, /webkit/playwright❌ Not supportedOnly 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.

LimitValue
Captured state size2 MB
Distinct origins per profile50
IndexedDB databases per origin5
IndexedDB entries per object store1,000
Profile name length255 characters
Profile name uniquenessPer token
Creation session lifetime10 minutes
Unused-profile retention30 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.

TimeoutWhere to set itWhat it controls
Session timeout?timeout= query param on POST /profileHow long the creation browser session stays alive
LiveURL timeout{ timeout: ms } param in Browserless.liveURL CDP callHow 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 localStorage keys 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