Skip to content

Profiles, cookie sync, secrets and 2FA

Most jobs worth automating sit behind a login. There are three ways to get a hosted browser signed in without putting a password in your code: sign in once and keep the session in a profile, copy cookies you already have into one with cookie sync, or let an agent run type the login from secrets the model never sees, 2FA codes included.

Profiles

A profile keeps a session's cookies, localStorage and IndexedDB under a name. Start a session with profile: { name: "shop-account", persist: true } to load it and save it back when the session ends, or with profile: "shop-account" to load it read-only. Unless you pass identity or fingerprint, a profile also brings its own identity: the same device, and the same residential IP while that IP stays online. The full rules are under Profiles: sign in once; from the SDK, see a persistent context on a cloud profile.

To see what a profile holds, without its values:

bash
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/profiles/shop-account
# {
#   "name": "shop-account",
#   "cookies": 42,
#   "domains": ["example.com", "shop.example.com"],
#   "bytes": 18211,
#   "storage": "Local Storage,IndexedDB",
#   "updatedAt": "2026-10-02T09:20:11.000Z"
# }

domains lists up to 50 sites, sorted, without a leading dot. bytes is the compressed size. storage names the site storage saved with it (Local Storage, IndexedDB); it is empty for a profile that only holds imported cookies. An unknown name is a 404 NOT_FOUND.

No endpoint returns a cookie value. A profile's contents are only opened on our servers: to merge an import, to count what it holds, and on the server running a session on it.

If you are already signed in somewhere (your own browser, a local Clearcote, a Playwright script), copy those cookies into a cloud profile and every session on that profile starts signed in:

bash
curl -X PUT https://www.clearcotelabs.com/api/v1/browsers/profiles/shop-account/cookies \
  -H "authorization: Bearer cc_live_..." -H "content-type: application/json" \
  -d '{
    "mode": "merge",
    "cookies": [
      { "name": "session_id", "value": "…", "domain": "shop.example.com", "path": "/",
        "secure": true, "httpOnly": true, "sameSite": "Lax", "expires": 1798761600 }
    ]
  }'
# { "name": "shop-account", "cookies": 42, "imported": 1, "domains": ["example.com", "shop.example.com"],
#   "bytes": 18211, "updatedAt": "…" }
  • Any common cookie format. The CDP shape (Network.getCookies, Network.Cookie / CookieParam) and the Playwright shape (context.cookies(), a storage-state file's cookies) both work as they are; a url may stand in for domain. No expires, expires: -1 (any value up to 0) or session: true means a session cookie, and a cookie that has already expired is skipped. sameSite is Lax, Strict or None in any case, and None needs secure: true.
  • Checked like Chrome checks them. A cookie Chrome would refuse (a ; in a value, sameSite: "None" without secure, a __Secure- or __Host- cookie that breaks its rules, a path that does not start with /) makes the whole import a 400 whose message names it.
  • Stored as name, value, domain (lowercased), path (default /), secure, httpOnly, sameSite (only if you sent it) and expires (absent for a session cookie). Other fields, such as partitionKey, are dropped.
  • mode: "merge" (the default) adds your cookies and replaces any with the same name, domain and path. mode: "replace" makes the profile's cookies exactly the list you send. Either way the profile's localStorage and IndexedDB are kept.
  • Limits: at most 5000 cookies per request, and 5000 in the profile after a merge (400 TOO_MANY); names up to 256 characters, values up to 4096, paths up to 1024; the whole profile at most 3.5 MB compressed (413 TOO_LARGE); 60 imports per 10 minutes.
  • A profile that does not exist yet is created. While a session that saves to the profile (persist: true) is starting or running, an import is refused with 409 PROFILE_IN_USE: the session would overwrite it when it ends.
  • Imported session cookies (no expiry) are loaded into the next sessions like any other. A session that saves the profile drops them, as a real browser does on restart, so import them again if you need them after that.

The answer counts the profile's cookies after the import (cookies) and how many of yours it took (imported: expired cookies and duplicates are left out), and lists the first 50 of its domains, sorted.

From your machine, with the SDK

The clearcote command reads cookies from where you have them and uploads only the domains you name:

bash
# a local Clearcote profile folder (the user_data_dir of launch_persistent_context)
clearcote cloud profile sync shop-account --from-profile ./profiles/shop --domain example.com

# a browser that is running with a CDP endpoint, for example clearcote serve
clearcote cloud profile sync shop-account --from-cdp http://127.0.0.1:9222 --domain example.com

# a Playwright storage-state file: context.storage_state(path="state.json")
clearcote cloud profile sync shop-account --from-file state.json --domain example.com --domain example-cdn.net

# sign in by hand: opens a local Clearcote window on a throwaway profile at the URL,
# you sign in and press Enter, and the cookies for the chosen domains are uploaded
clearcote cloud profile sync shop-account --login https://shop.example.com/login --domain example.com
  • --domain (repeat it for several) picks the cookies a browser would send to that domain: its own, its subdomains', and those of its parent domains (--domain shop.example.com also takes .example.com cookies, never ones on a bare suffix such as .com). Without --domain or --all-domains the command refuses to run, so nothing uploads every cookie in a profile by accident.
  • --all-domains uploads everything. Only use it for a profile made for this one job.
  • --replace sends mode: "replace"; the default merges.
  • From code, profiles.sync(name, …) takes the same choices: exactly one of from_profile, from_cdp, from_file or login_url (Node: fromProfile, fromCdp, fromFile, loginUrl), then domains=[…] or all_domains=True (allDomains: true), and replace=True. It returns the import answer above, and raises ValueError (an Error in Node) without uploading anything when no cookie matches.
from clearcote import launch_persistent_context
from clearcote.cloud import Cloud

cloud = Cloud()

# the same as the command line
cloud.profiles.sync("shop-account", from_file="state.json", domains=["example.com"])

# or upload cookies you have in hand, here from a local profile you signed in with
# (cloud=False: this folder is on your machine, whatever CLEARCOTE_CLOUD says)
ctx = launch_persistent_context("./profiles/shop", cloud=False)
cookies = ctx.cookies("https://shop.example.com")       # only this site's cookies
ctx.close()
cloud.profiles.import_cookies("shop-account", cookies)  # mode="merge"

A cookie is as good as a password while it is valid: whoever holds a session cookie is signed in as you. Upload the domains the job needs and nothing else. Some sites tie a login to the device or IP it was made on and sign a copied cookie out; for those, sign in once inside a hosted session with persist: true instead.

Secrets and 2FA

An agent run can type values the model never sees: passwords, account numbers, and the current code from a TOTP authenticator. Put them in secrets and refer to them in the task as {{name}}:

import os
from clearcote.cloud import Cloud

run = Cloud().runs.create(
    "Sign in with {{email}} and {{password}}. If a code is asked for, enter {{otp}}. "
    "Then open Orders and read the number of the latest order.",
    url="https://shop.example.com/login",
    secrets={
        "email": "me@example.com",
        "password": {"value": os.environ["SHOP_PASSWORD"]},
        "otp": {"totp": os.environ["SHOP_TOTP_SECRET"]},
    },
    schema={"type": "object", "properties": {"order": {"type": "string"}}, "required": ["order"]},
    profile={"name": "shop-account", "persist": True},   # next time it starts signed in
)
print(run["status"], run["result"]["output"])
FieldTypeMeaning
"name": "value"stringA plain value, typed on the host of the run's url (and its subdomains).
valuestringThe value, 1 to 1024 characters.
totpbase32 stringA 2FA secret instead of a value: the setup key shown next to the QR code (letters A–Z and digits 2–7, at most 128; spaces and lowercase are fine). The run types the current code.
digits6 | 8TOTP only. Default 6.
period30 | 60TOTP only, in seconds. Default 30.
domainsstring[]The hosts it may be typed on: 1 to 10 host names such as example.com (at least two labels; no scheme, path, port or *), each including its subdomains. Default: the host of url.
  • A secret is a string, { value, domains? } or { totp, domains?, digits?, period? }. At most 20 per run. Names start with a letter and use letters, digits and _, up to 32 characters.
  • A {{name}} in the task that is not in secrets is a 400 (unknown secret "name"). So is a secret with no domains on a run with no url: there is no host to bind it to. Write the reference exactly as {{name}}, without spaces.
  • A secret the task never mentions is allowed; the create answer's warnings says so.
  • Mind the host. The default binds a secret to the host of url and its subdomains: https://shop.example.com/login covers shop.example.com, not login.example.com. If the login form lives on another host (single sign-on, an identity provider), list it in domains.
  • TOTP codes follow RFC 6238 with SHA-1, which is what authenticator apps use. The code is worked out at the moment it is typed, so it is always the current one.
  • Secrets belong to one run. They are not stored with a profile and not kept after the run: send them again with the next one (or run the next one on a signed-in profile and skip the login).

What the model sees

Before Jet starts, every {{name}} in the task is replaced with a random placeholder such as CCSECRET_password_k3x9qa, and the task gets one sentence saying placeholders are to be typed exactly as written. That is all the model ever gets. When Jet types a placeholder, the browser layer on the server swaps in the real value, or the current TOTP code, at that moment, and only if the page's host is one of the secret's domains or a subdomain of one (for a field in an iframe, the frame's host too). On any other host the step stops with needs_input (“secret password is not allowed on other.example”) and nothing is typed.

Everything that comes back is cleaned the same way: in detail, markdown, each step's text, the output JSON and the events, a secret's value, its current TOTP code and its placeholder all read {{name}}. One consequence: if the page shows a secret (say, the email address after sign-in) and your schema asks for it, you get {{email}}, not the address.

Security model

What protects what, and where the limits are:

  1. Your API key is your account. It can start browsers on your balance, use your profiles and read your runs. Keep it on a server, never in a browser or an app you ship.
  2. Profiles are private and encrypted. Each is stored encrypted (AES-256-GCM, with a key per customer) and opened only on our servers: to merge an import, to count what it holds, and on the server running a session on it. Another customer's profile with the same name is a different profile. No endpoint returns a cookie value (a session on the profile can, of course, read its own cookies over CDP).
  3. Cookie sync uploads what you choose. Only you can import into your profiles, within the limits above (5000 cookies, 3.5 MB), and never into one that a starting or running session is saving to. Picking the domains happens on your side: clearcote cloud profile sync and profiles.sync() refuse to upload a whole cookie jar unless you pass --all-domains / all_domains=True.
  4. Secrets are sealed until the run starts. They are stored encrypted the same way, inside the run's settings, and opened only when a server picks up the run. They are handed to that server once, and the stored settings are erased at that point. On the server they stay in memory and go to the Jet process on its standard input: never in environment variables, command lines, logs or events. If they can no longer be opened when a server picks the run up, the run ends failed with session.endReason secrets_unreadable.
  5. The model never receives a secret. It works with placeholders, and the real value is only typed on the hosts you allowed. A page that tries to talk the agent into typing your password somewhere else (prompt injection) gets a refused step, not the password.
  6. Outputs are scrubbed. Values, TOTP codes and placeholders are replaced with {{name}} in everything a run returns or stores.
  7. Jet runs apart from the server's own credentials. Each run is a separate process with an explicit, minimal environment: the server's keys and the proxy credentials are never passed to it or to the browser. The browser keeps the hosted guards: no local files, no private networks.

And what this does not cover:

  • The site you sign in to gets the value. That is the point; choose domains with care.
  • The page can show it. After a value is typed into an ordinary field, the page displays it. Jet swaps a typed value back to its placeholder before the model reads the page, but only where it appears exactly as typed (or URL-encoded): a page that shows it changed, for example reformatted or partly hidden, is not caught. Password fields are never read.
  • Recordings show the screen. A value in an ordinary field is on the video. Leave record off for logins you do not want on file.
  • The task is not secret. It is stored with the run and shown in the dashboard and the run list. Never write a value into the task itself; always use {{name}}.
  • Links are keys. A hand-off or control link lets whoever has it drive the browser, signed in, until it expires. Send it only to the person who should act.