Persist authenticated sessions with Profiles

Maintain authenticated sessions across Steel browser instances using profiles.

examples/profiles-ts
Contributors: , Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A Steel profile is a named, long-lived browser identity. It holds everything a real Chrome user profile accumulates over time: cookies, localStorage, IndexedDB, history, installed extensions, autofill, site permissions. Every session you attach to the profile starts where the last one left off, and writes the user data directory back on release.

Two options on sessions.create wire it up. On the first run, mint a fresh profile:

session = await client.sessions.create({
persistProfile: true,
profileId: undefined,
});
const profileId = session.profileId;

persistProfile: true tells Steel to snapshot the browser data directory when the session ends. profileId: undefined means "create a new one." After the session is created, session.profileId holds the identifier. Store it. Every later run passes it back:

session = await client.sessions.create({
persistProfile: true,
profileId,
});

The new browser opens as that identity. client.profiles.list(), client.profiles.retrieve(id), and client.profiles.delete(id) round out the surface.

How the demo works

index.ts uses demowebshop.tricentis.com, a public shopping cart demo that stores cart state in cookies. The flow lives in main() and three helpers in utils.ts:

  1. 1selectOrCreateProfile calls client.profiles.list(), then inquirer prompts you to pick an existing profile or create a new one. Returns undefined to signal "mint a fresh one."
  2. 2On a fresh run, Session #1 launches with persistProfile: true and no profileId. addItemsToCart visits three category pages (books, digital downloads, notebooks) and clicks the first add-to-cart button on each. Session #1 releases; Steel writes the profile snapshot.
  3. 3Session #2 launches with the same profileId. checkItemsInCart opens /cart and reads the rows. Same identity, different browser, same cart.

Select the saved profile on a later run and the menu skips step 2 entirely. One session spins up, finds the cart intact, and exits. That is the test: state survives across distinct sessions because the profile carries it.

Run it

cd examples/profiles-ts
cp .env.example .env # set STEEL_API_KEY
npm install
npm start

Get a key at app.steel.dev/settings/api-keys. Session viewer URLs print as the script runs. Open them in other tabs to watch each browser.

Your first-run output varies. Structure looks like this:

Steel Profiles Demo
============================================================
? Select a profile to use: Create a new profile
Steel Session #1 created!
View session at https://app.steel.dev/sessions/ab12cd34...
Profile ID: prof_9f3c...
Successfully logged in
Added item from Book category
Added item from Digital Download category
Added item from Notebook category
3 items in cart
Items added:
1. Computing and Internet
2. Music 2
3. Fiction
Session #1 released
Steel Session #2 created!
View session at https://app.steel.dev/sessions/ef56gh78...
Found 3 items in cart
Found your shopping cart!
Session released

Full round-trip takes ~60 seconds. A second run against the saved profile takes ~30 seconds because step 2 is skipped.

Sessions go through client.sessions.release() in the finally block. Skipping it keeps browsers running until the 5-minute default timeout and delays the profile snapshot.

What persists

Profiles capture the full Chromium user data directory, not just cookies:

  • Cookies and localStorage for every origin you visited.
  • Login sessions you kept alive (bank, SaaS dashboard, email).
  • IndexedDB entries for apps that cache state client-side.
  • Installed extensions and their configuration.
  • Autofill, history, bookmarks, site permissions.

Treat the profile like an account. Anyone who can call sessions.create({ profileId }) on your workspace can drive a browser logged in as you. Rotate or delete with client.profiles.delete(id) when the identity is done.

Make it yours

  • Swap the target site. Replace the URLs in login, addItemsToCart, and checkItemsInCart. The profile plumbing does not change.
  • Seed a profile interactively. Create a session with persistProfile: true, open the live viewer, sign in by hand, close the session. The profile keeps the login. Every scripted run after that reuses it.
  • One profile per identity. If you automate three accounts on the same site, create three profiles. Sharing a profile across accounts means one session's writes overwrite another's state on release.
  • Read without writing back. Pass persistProfile: false with an existing profileId to load the profile without snapshotting changes on release. Useful for risky runs that might corrupt state.

Three recipes handle "start the browser already signed in." Pick by lifetime:

  • credentials: Steel stores a username and password per origin and fills the login form each session. No browser state persists. Good when the form is standard and you want a stable, long-lived setup.
  • auth-context: one-shot JSON snapshot of cookies and localStorage you capture from one session and replay into the next. Good when you log in once (SSO, MFA, magic link) and want to move the resulting state forward.
  • Profiles (this recipe): long-lived named identity that accumulates everything (history, extensions, preferences, logins) across runs. Good when the browser itself is the unit of persistence.

Playwright docs

examples/profiles-py
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A Steel profile is a named, long-lived browser identity. It carries everything a real Chrome user data directory accumulates: cookies, localStorage, IndexedDB, history, installed extensions, autofill, site permissions. Attach a session to a profile and the browser opens where the last one left off. Release the session and Steel snapshots the data directory back into the profile.

Two arguments on client.sessions.create wire this up. The first run mints a profile by asking for persistence and passing no id:

session = client.sessions.create(persist_profile=True)
profile_id = session.profile_id

persist_profile=True tells Steel to write the browser data directory back when the session ends. With no profile_id, Steel creates a fresh one and returns it on the session object. Store that id. Every later run passes it back:

session = client.sessions.create(persist_profile=True, profile_id=profile_id)

The new browser opens as that same identity. client.profiles.list(), client.profiles.retrieve(id), and client.profiles.delete(id) round out the surface.

How the demo works

main.py runs a straight two-session flow against demowebshop.tricentis.com, a public shopping cart demo that keeps cart state in cookies:

  1. 1Session #1 launches with persist_profile=True and no profile id. add_first_book_to_cart opens /books, clicks the first add-to-cart button, and waits for .cart-qty to move off (0). The session releases and Steel snapshots the profile.
  2. 2Session #2 launches with the captured profile_id. count_cart_rows opens /cart and counts .cart tbody tr. A row count above zero means the cart survived a browser that no longer exists, carried forward by the profile.

This is a port of ../profiles-ts, reworked to run end to end without input. The TypeScript version opens an inquirer picker to choose an existing profile or mint a new one. This Python version drops the picker and always creates a fresh profile, then reuses it once, so the persistence round-trip happens in a single run.

Run it

cd examples/profiles-py
cp .env.example .env # set STEEL_API_KEY
uv run main.py

Grab a key at app.steel.dev/settings/api-keys. uv sync runs automatically on first uv run, so there is no separate install step. The two viewer URLs print as the script runs. Open them in other tabs to watch each browser.

Your output varies. Structure looks like this:

Steel Profiles Demo (Python)
============================================================
Session #1: https://app.steel.dev/sessions/ab12cd34...
Profile ID: prof_9f3c...
Added a book to the cart (cart shows Shopping cart (1))
Session #1 released, snapshotting profile...
Session #2: https://app.steel.dev/sessions/ef56gh78...
Profile ID: prof_9f3c...
Success: cart persisted across sessions with 1 item(s) via the profile
Releasing session...
Session released
Done!

Both sessions go through client.sessions.release(). Session #1 is released inline so its profile snapshot lands before Session #2 opens; Session #2 is released in the finally block. Skipping release keeps browsers running until the default timeout and delays the snapshot.

What persists

Profiles capture the full Chromium user data directory, not just the cart cookie this demo touches:

  • Cookies and localStorage for every origin you visited.
  • Login sessions you kept alive (bank, SaaS dashboard, email).
  • IndexedDB entries for apps that cache state client-side.
  • Installed extensions and their configuration.
  • Autofill, history, bookmarks, site permissions.

Treat a profile like an account. Anyone who can call client.sessions.create(profile_id=...) on your workspace can drive a browser logged in as you. Delete one with client.profiles.delete(id) when the identity is done.

Make it yours

  • Swap the target site. Replace the URLs in add_first_book_to_cart and count_cart_rows. The profile plumbing does not change.
  • Seed a profile by hand. Create a session with persist_profile=True, open the live viewer, sign in yourself, then release. The profile keeps the login, and every scripted run after that reuses it.
  • One profile per identity. Automating three accounts on the same site means three profiles. Sharing one across accounts lets a later session's snapshot overwrite an earlier one's state.
  • Read without writing back. Pass persist_profile=False with an existing profile_id to load a profile without snapshotting changes on release. Useful for risky runs that might corrupt state.

Three recipes solve "start the browser already signed in." Pick by lifetime:

  • credentials-py: Steel stores a username and password per origin and fills the login form each session. No browser state persists.
  • auth-context-py: a one-shot JSON snapshot of cookies and localStorage captured from one session and replayed into the next.
  • Profiles (this recipe): a long-lived named identity that accumulates everything across runs.

Other ports of this recipe: profiles-ts (interactive picker), profiles-go, profiles-rs. See the Playwright docs for the Python browser API.

examples/profiles-rs
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A Steel profile is a named, long-lived browser identity: the full Chromium user data directory (cookies, localStorage, IndexedDB, history, extensions, autofill, permissions) snapshotted on release and reloaded on the next attach. Two fields on SessionCreateParams drive it. persist_profile: Some(true) tells Steel to write the data directory back when the session ends. profile_id selects which identity to load: leave it None to mint a fresh one, or pass a captured id to resume.

The whole demo turns on one value moving between two create calls:

let session = client
.sessions()
.create(SessionCreateParams {
persist_profile: Some(true),
..Default::default()
})
.await?;
let profile_id = session.profile_id.clone().ok_or("no profile_id")?;

SessionCreateParams derives Default, so struct-update syntax sets only the two profile fields and leaves the rest at their server defaults. The first session returns a profile_id; the second passes it back with profile_id: Some(profile_id.clone()) and the same persist_profile: Some(true). Same identity, a brand new browser.

What the demo does

This recipe is non-interactive. The TypeScript sibling prompts you to pick a profile with inquirer; here both sessions run end to end with no input, so a single cargo run proves the round trip. It drives demowebshop.tricentis.com, a public shopping cart demo that keeps cart state in the browser, over CDP with chromiumoxide:

  1. 1Create session #1 with persist_profile: Some(true), capture session.profile_id, connect, open /books, and click the first add-to-cart button (.product-box-add-to-cart-button, falling back to input[value="Add to cart"]). Waiting for .cart-qty to appear confirms the click landed.
  2. 2Release session #1 so Steel writes the profile snapshot, then sleep ~3 seconds to let the write settle.
  3. 3Create session #2 with the same persist_profile: Some(true) and the captured profile_id, connect, open /cart, and count .cart tbody tr rows with a one-line page.evaluate. More than zero rows means the cart crossed the session boundary.

Each chromiumoxide connection spawns a handler task (tokio::spawn) to pump CDP events; handle.abort() stops it before the session is released.

Run it

cd examples/profiles-rs
cp .env.example .env # set STEEL_API_KEY
cargo run

Get a key at app.steel.dev/settings/api-keys. Both session viewer URLs print as the run proceeds. Open them in other tabs to watch each browser.

Creating Steel session #1 with a fresh persisted profile...
Profile ID: prof_9f3c...
Session #1 live at https://app.steel.dev/sessions/ab12cd34...
Added the first book to the cart
Session #1 released
Creating Steel session #2 from profile prof_9f3c...
Session #2 live at https://app.steel.dev/sessions/ef56gh78...
Found 1 item(s) in the cart
Session #2 released
Profile persistence confirmed: the cart survived across sessions

A full round trip takes ~30 seconds. Both sessions go through client.sessions().release(...) before the program exits; skip it and the browsers idle until the 5-minute default timeout, which also delays the profile snapshot.

Make it yours

  • Swap the target. Change BOOKS_URL, CART_URL, and the selectors. The two-create profile plumbing stays the same for any site whose state lives in the browser.
  • Resume an existing profile. Skip session #1 and start at session #2 with a profile_id you saved earlier. Seed it once by hand: create a session with persist_profile: Some(true), sign in through the live viewer, release, and reuse the id forever.
  • Read without writing back. Pass persist_profile: Some(false) with an existing profile_id to load the identity without snapshotting changes on release. Good for risky runs that might corrupt state.
  • Manage the identity. client.profiles().list(), retrieve, and delete round out the surface. Treat a profile like an account: anyone who can call create with its id drives a browser logged in as you.

profiles-ts · profiles-py · profiles-go · auth-context-rs · chromiumoxide

examples/profiles-go
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

A Steel profile is a named, long-lived browser identity. It carries everything a real Chrome user profile accumulates: cookies, localStorage, IndexedDB, history, installed extensions, autofill, site permissions. Attach a session to a profile and the browser opens where the last one left off; on release, Steel writes the user data directory back to the profile.

Two fields on Sessions.Create wire it up, both through the steel.F(...) field wrapper. To mint a fresh profile, pass PersistProfile: steel.F(true) and leave ProfileID unset. The created *steel.Session exposes .ProfileID. Store it. Every later run passes it back as ProfileID: steel.F(profileID) alongside PersistProfile, and the browser opens as that identity.

Non-interactive by design

The TypeScript sibling opens an inquirer menu so you can pick an existing profile or create a new one. This Go port drops the picker and runs the full round-trip end to end in one invocation: seedCart mints a profile and adds an item, the program sleeps about three seconds so the snapshot settles, then verifyCart opens a second session from the same ProfileID and counts the cart rows. Nothing to click. To reuse a profile from a previous run, read the printed Profile ID and feed it into Sessions.Create yourself.

chromedp talks CDP directly: chromedp.Evaluate runs the cart logic in the page (document.querySelector(".product-box-add-to-cart-button") with an input[value='Add to cart'] fallback, then .cart-qty for the header count and .cart tbody tr for the row count). chromedp.NewRemoteAllocator with chromedp.NoModifyURL attaches to the Steel browser over the websocket URL, the same idiom as the chromedp recipe.

Run it

cd examples/profiles-go
cp .env.example .env # set STEEL_API_KEY
go mod tidy
go run .

Get a key at app.steel.dev/settings/api-keys. Both session viewer URLs print as the program runs; open them in other tabs to watch each browser.

Steel Profiles Demo
============================================================
Session #1 created with a fresh profile.
View live at https://app.steel.dev/sessions/ab12cd34...
Profile ID: prof_9f3c...
Adding the first book to the cart...
Added item. Header cart count now reads "(1)".
Releasing session #1...
Waiting for the profile snapshot to settle...
Session #2 created from profile prof_9f3c...
View live at https://app.steel.dev/sessions/ef56gh78...
Opening the cart in the new browser...
Releasing session #2...
------------------------------------------------------------
Profile ID: prof_9f3c...
Session #1 viewer: https://app.steel.dev/sessions/ab12cd34...
Session #2 viewer: https://app.steel.dev/sessions/ef56gh78...
Found 1 item(s) in the cart. Profile persistence works.

Both sessions release through the release helper deferred right after each Sessions.Create. Skipping release keeps browsers running until the default timeout and delays the profile snapshot.

Make it yours

  • Swap the target site. Replace booksURL, cartURL, and the three Evaluate snippets. The profile plumbing does not change.
  • Add more items. Loop the click snippet over several category pages before releasing session #1, and the whole cart rides the profile forward.
  • Seed a profile by hand. Create one session with PersistProfile: steel.F(true), open its live viewer, sign in manually, release. The login lives in the profile, and every scripted run after that reuses it via ProfileID.
  • Read without writing back. Pass PersistProfile: steel.F(false) with an existing ProfileID to load the profile without snapshotting changes on release. Useful for risky runs that might corrupt state.

Three recipes handle "start the browser already signed in." Pick by lifetime:

  • auth-context: one-shot JSON snapshot of cookies and localStorage you capture from one session and replay into the next. Good when you log in once (SSO, MFA, magic link) and want to move that state forward.
  • Profiles (this recipe): long-lived named identity that accumulates everything (history, extensions, preferences, logins) across runs. Good when the browser itself is the unit of persistence.
  • Sibling ports: profiles-ts, profiles-py, profiles-rs.

chromedp docs