Back to Knowledge Hub
Identity

Browser Cookie Management for Multi-Identity Workflows

How cookies carry session and consent state, why cookies added after page creation can miss the first request, and how to load a separate cookie set per identity at launch.

BotBrowser Team

Documentation

Prefer the maintained product doc?

This article has a matching page in the docs center. Use the docs for the canonical setup flow, current flags, and long-term reference.

Cookies are small pieces of state that a server asks the browser to store and send back with later requests. Sites use them for sign-in sessions, language and theme preferences, and the record of a consent choice. For a developer or QA engineer testing a site, that makes the cookie store part of the test input. The same page behaves differently for a first-time visitor, for a returning visitor who already accepted a consent banner, and for a signed-in account. A test that does not control its starting cookie set is measuring one of those cases by accident.

The sections below follow a cookie from the server's header to the browser's store, explain why cookies added through a framework API can arrive after the first request, and show how a launch-time cookie set loaded with the BotBrowser --bot-cookies flag gives each identity a documented, repeatable starting state. They also name what a cookie store does not isolate, because a separate cookie file is only one layer of a separate identity.

Two timelines. With launch-time loading, the cookies are in the store before the page is created, so the first request carries them. With a framework call made after the page started loading, the first request goes out without cookies and only later requests carry them.

The diagram compares two orders of events. When the cookies are in the store before any page exists, the first request carries them. When a script adds them after a page has already started loading, the earlier request goes out without them.

RFC 6265 defines the mechanism. A server sends a Set-Cookie response header with a name, a value and optional attributes. The browser stores the cookie and, on later requests to a matching host and path, sends it back in a Cookie request header. Scripts on the page can read and write cookies through document.cookie, except for cookies marked HttpOnly, which the browser keeps out of reach of page scripts. MDN's cookie guide describes the same flow and adds the SameSite attribute, which came after the original specification.

Six attributes decide whether a stored cookie is sent with a given request:

  • Domain and host-only cookies. A cookie set without a Domain attribute belongs to the exact host that set it. A cookie with Domain=example.com is also sent to subdomains of that host.
  • Path. The cookie is sent only for request paths that match the stored path.
  • Expires and Max-Age. A cookie with neither is a session cookie and is meant to end with the browsing session. A cookie with a future date is persistent and survives a restart.
  • Secure. The cookie is sent only over HTTPS.
  • HttpOnly. The cookie is sent with requests but is hidden from document.cookie.
  • SameSite. The values Strict, Lax and None control whether the cookie travels with cross-site requests, and None is used together with Secure.

For a test, the attributes often matter more than the value. A cookie stored under the wrong domain, the wrong path, or with Secure on a plain HTTP test site is stored without error and then never sent, which looks like the site ignoring your setup. RFC 6265 sets only minimum limits on how many cookies a browser keeps and leaves eviction to the implementation, and browsers have changed their defaults for attributes such as SameSite over time. Treat both the specification and the MDN guide as the reference, and confirm the behavior in the browser version you actually test with.

Consent cookies need one more note. There is no standard name or value for a consent choice. Each site or consent platform defines its own cookie, its own value format and its own lifetime, and the names and values used in the examples below are illustrative. To build a realistic starting state, make the choice once in an ordinary browser session that you control, read the cookie the site sets in response, and copy its name, value format, domain, path and expiry. A cookie that you invent from guesswork may simply be ignored by the site, and the test then shows a banner that you believed you had dismissed.

Why Cookies Added Through Framework APIs Can Miss the First Request

Automation frameworks give you cookie calls of their own. Playwright has context.addCookies() and Puppeteer has page.setCookie(). Both are calls that your script makes after the browser context or the page already exists. The browser starts, the context and page are created, and only then does your call arrive. From that moment on the cookies are in the store. A request that was sent before the call carries none of them.

In many scripts that gap is harmless. If you create a context, add cookies, and only then call goto() for the first time, the first navigation already carries them. The gap shows up when something loads before your call. A page that the browser or a launcher opens at startup may begin loading on its own. A test runner fixture may create the page in one place and add cookies in another. A shared helper may open the first page for you. In each case the earliest request is sent while the cookie store is still empty, and which of those orderings you get can depend on timing that you do not control.

Consent and returning-visitor tests are the cases that feel this most. The first response is often the one that decides whether the server renders a consent banner, or issues a fresh session cookie for a visitor it has never seen. If that response is produced for a cookie-free request, the server may set its own cookie, and the cookies you add a moment later can collide with it or arrive too late to matter. The test then reports what happened to a new visitor even though the intent was to test a returning one.

Launch-time loading changes the order. According to the BotBrowser documentation, cookies passed with --bot-cookies are loaded before the first page navigation, so the first HTTP request already includes them. That is the only comparison drawn here: when the cookies become available relative to the first request. Framework APIs stay useful for changing cookies in the middle of a test, and they do not need to be replaced for that.

To find out whether the gap affects your own setup, log the URL and the cookie header of every request from the moment the browser starts, then compare the first entries with the cookies you meant to have. If the first request already carries them, your ordering is fine and a framework call is enough. If the earliest entries have no cookie header, move that cookie set into the launch-time flag. The check takes a few minutes and replaces guesswork about timing with a record you can attach to the test.

The --bot-cookies flag requires the PRO tier. It accepts a JSON array of cookie objects, either inline in the flag value or from a file when the value starts with @. Inline JSON suits a small set, such as a single consent cookie:

chromium-browser \
  --bot-profile="path/to/profile.enc" \
  --bot-cookies='[{"url":"https://example.com","name":"consent","value":"accepted","domain":".example.com"}]'

A file suits a larger set and keeps the launch command readable. The documentation's troubleshooting advice is to use an absolute path after the @:

[
  {
    "url": "https://example.com",
    "name": "session_id",
    "value": "abc123",
    "domain": ".example.com",
    "path": "/",
    "secure": true,
    "httpOnly": true,
    "expirationDate": 1893456000
  },
  {
    "url": "https://example.com",
    "name": "locale",
    "value": "en-US",
    "domain": ".example.com",
    "path": "/"
  }
]

Each cookie object supports these fields, as listed in the documentation:

  • url (required): the full URL used to set the cookie, for example https://example.com. A cookie without it is silently skipped.
  • name and value (required): the cookie data itself.
  • domain: the domain the cookie belongs to. A leading dot, as in .example.com, includes subdomains.
  • path: defaults to /.
  • secure: defaults to true, so the cookie is sent only over HTTPS.
  • httpOnly: defaults to false.
  • sameSite: strict, lax or none.
  • expirationDate: a Unix timestamp in seconds since the epoch.

Three details cause most of the confusion. The first is the url field. Skipping a cookie without it is silent, so one missing field produces no error and a smaller cookie set than you expected. Count the cookies after launch instead of assuming they loaded. The second is secure, which defaults to true. In our reading of that default, if your test site runs over plain HTTP, set it to false explicitly, or the cookie is stored and never sent. The third is the way the flag is built in code. Pass the output of JSON.stringify() as the value without wrapping it in extra quotes, because extra quotes become part of the value and the JSON no longer parses. Also use a future expirationDate in seconds, not milliseconds, so the cookie does not expire the moment it loads.

A Playwright launch with one identity, one profile and one cookie file looks like this:

import { chromium } from 'playwright-core';
const browser = await chromium.launch({
  executablePath: process.env.BOTBROWSER_EXEC_PATH,
  headless: true,
  args: ['--bot-profile=profiles/identity-a.enc', '--bot-cookies=@/absolute/path/cookies/identity-a.json'],
});
const page = await browser.newPage();
await page.goto('https://example.com/');

Keeping Identities and Contexts Separate

Keep one cookie file per identity and name it after the identity. Pair it with that identity's own profile and do not copy a file from one identity to another, because a copied cookie set carries the first identity's consent choices and session records into the second. When you rotate a profile, review the cookie file at the same time, since a consent record or locale preference that matched the old profile may no longer match the new one. Keeping the files in one documented folder, with access limited to the people who run the tests, also gives a reviewer something concrete to compare between runs, which is the point of a repeatable starting state. Mixing files makes results hard to attribute: if a consent test and a signed-in test share one file, a failure could come from either state, and nobody can tell which without rerunning both. Separate files turn that question into a lookup. The multi-account isolation guide and the profile management guide cover how to organize profiles and the files that go with them.

The scope of the flag depends on how you pass it. The BotBrowser documentation says cookies loaded at launch become the startup state of the browser. With Puppeteer, use browser.defaultBrowserContext() to reach that context, since context.cookies() on another context may return an empty list. With Per-Context Fingerprint, --bot-cookies can instead be passed through botbrowserFlags when a BrowserContext is created, and the cookies are then imported into that context only. Each context starts with its own cookie state while sharing one browser process:

const client = await browser.newBrowserCDPSession();
const cookies = JSON.stringify([
  { url: 'https://example.com', name: 'consent', value: 'accepted', domain: '.example.com', path: '/' },
]);
const { browserContextId } = await client.send('Target.createBrowserContext', {
  botbrowserFlags: ['--bot-profile=profiles/identity-a.enc', '--bot-cookies=' + cookies],
});

Create pages only after the context exists, so that the first navigation already uses the context's cookie state.

A cookie store on its own does not isolate everything that makes two identities independent. RFC 6265 is explicit about several limits, and the others follow from what the flag documents:

  • Cookies are not isolated by port, so two services on the same host share the cookies for that host.
  • A cookie for a parent domain is visible to every subdomain, and a sibling subdomain can set a cookie that another one will read.
  • Local storage, IndexedDB, caches and service workers are separate stores. The documentation for --bot-cookies does not describe pre-loading them.
  • A cookie file says nothing about the fingerprint profile, the language settings or the network route that go with the identity.

The browser storage guide explains how those other stores differ from cookies. If an identity must start without leftover state, check what a reused --user-data-dir carries along as well, because it keeps all of those stores together, not just cookies.

Checking That the Starting State Is What You Meant

Three checks cover the starting state: what is in the store, what the first request carries, and what the page can read. The following sketch checks the first two with Playwright:

page.on('request', async request => {
  const headers = await request.allHeaders();
  console.log(request.url(), 'cookie' in headers);
});
await page.goto('https://example.com/');
console.log(await page.context().cookies('https://example.com/'));

Run the request listener before the first navigation, so the earliest request is included. The cookie list should contain every cookie from your file, and the first request should show a cookie header. If the count is lower than the file, look for a missing url field before anything else. Keep in mind that a cookie marked HttpOnly will not appear in document.cookie. That is how the attribute is defined, so its absence on the page is not a failure. Check the store and the request header for those cookies instead.

Compare the cookie flags with what the target site really sets. Open the site normally in a throwaway browser profile, look at its Set-Cookie headers in the network panel, and match Domain, Path, Secure, HttpOnly, SameSite and the expiry. A pre-loaded cookie whose flags differ from the real one may be treated differently from the cookie the site would have issued, so the test then covers a state that real visitors never have.

When a cookie does not arrive, work through the causes in the order they usually occur. First, check the url field, because a skipped cookie leaves no trace. Second, compare domain and path with the request URL, remembering that a host-only cookie does not reach subdomains. Third, look at secure and the scheme of the site. Fourth, consider sameSite, since a cookie marked lax or strict may not travel with a cross-site request. Fifth, confirm that the expiry is in the future and in seconds. Sixth, make sure the flag value is valid JSON without extra quotes and that the file path is absolute. Finally, with Puppeteer, read the cookies from the default browser context.

A short review record keeps the setup repeatable. For each identity, write down:

  • the source of the cookie values, for example a consent state recorded in a test account or documented by hand;
  • the file assigned to that identity and the profile it is paired with;
  • the expiration and flags, matched to the target site's Set-Cookie behavior;
  • how you verified the pre-loaded state, and on which browser version.

If you export cookies at the end of a session to reuse them, note that cookie objects returned by a framework may not carry a url field. Add one to each object before passing the file to --bot-cookies, or the cookie is skipped. Load only cookies that belong to your own test accounts or documented consent states. Extracting, forging or replaying another party's session cookies is outside the scope of this setup, and a site may reject or expire any injected cookie regardless. Repeat the verification whenever the browser version, the profile or the cookie file changes, because a difference in the first request is easiest to explain when only one of those changed since the last passing run.

Where BotBrowser Fits and Where It Stops

BotBrowser documents loading cookies at launch with --bot-cookies (PRO tier), from inline JSON or an @ file, before the first page navigation so the first HTTP request already carries them, and importing them into a single BrowserContext when the flag is passed through botbrowserFlags, so teams can repeat a documented cookie starting state for returning-visitor and consent-flow tests and keep identities separate. BotBrowser does not change the cookie specification or the SameSite and Secure rules, it silently skips cookies that lack a url field, it does not document pre-loading localStorage or IndexedDB, and it cannot guarantee that a target site accepts, keeps, or links any injected cookie to a session.

Sources

#Cookies#Management#Identity#Session#Privacy

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.