diff --git a/browsers/per-user-sessions.mdx b/browsers/per-user-sessions.mdx
new file mode 100644
index 0000000..508c465
--- /dev/null
+++ b/browsers/per-user-sessions.mdx
@@ -0,0 +1,184 @@
+---
+title: "Per-User Sessions"
+description: "Run one authenticated browser per end user when your application owns the credentials"
+---
+
+When your application signs in your own users — you hold their credentials, or they enter them
+themselves — each user needs browser state that's isolated from every other user. This guide covers
+that shape: where the state lives, how to avoid corrupting it, how to keep cold starts off your
+latency budget, and what a per-user session costs.
+
+If you'd rather Kernel perform the login and keep it healthy for you, use
+[Managed Auth](/auth/overview) instead. Everything below assumes you're doing the login yourself.
+
+## Give each user their own profile
+
+A [profile](/auth/profiles) is the unit of per-user state: it carries cookies and local storage into
+a browser. Create one per user, drive your own login flow in a browser that references it, and set
+`save_changes` so the resulting session is written back when the browser is deleted.
+
+
+```typescript Typescript/Javascript
+await kernel.profiles.create({ name: 'user-8f21c3' });
+
+const kernelBrowser = await kernel.browsers.create({
+ profile: { name: 'user-8f21c3', save_changes: true },
+});
+
+// ... run your login flow as this user ...
+
+await kernel.browsers.deleteByID(kernelBrowser.session_id);
+```
+
+```python Python
+kernel.profiles.create(name="user-8f21c3")
+
+kernel_browser = kernel.browsers.create(
+ profile={"name": "user-8f21c3", "save_changes": True},
+)
+
+# ... run your login flow as this user ...
+
+kernel.browsers.delete_by_id(kernel_browser.session_id)
+```
+
+```go Go
+if _, err := client.Profiles.New(ctx, kernel.ProfileNewParams{
+ Name: kernel.String("user-8f21c3"),
+}); err != nil {
+ panic(err)
+}
+
+kernelBrowser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{
+ Profile: shared.BrowserProfileParam{
+ Name: kernel.String("user-8f21c3"),
+ SaveChanges: kernel.Bool(true),
+ },
+})
+if err != nil {
+ panic(err)
+}
+
+// ... run your login flow as this user ...
+
+if err := client.Browsers.DeleteByID(ctx, kernelBrowser.SessionID); err != nil {
+ panic(err)
+}
+```
+
+
+State is written on browser deletion, not on `browser.close()` — see [Profiles](/auth/profiles).
+
+Keep one profile per user per target site rather than accumulating every site a user touches into a
+single profile. Large profiles carry more cookies and origins, and that slows browser startup on
+every later session.
+
+## Keep one writer per profile
+
+Saving replaces a profile's entire stored state — it doesn't merge. If two browsers use the same
+profile with `save_changes: true`, the one that ends last wins and the other user's work is lost
+silently. In a per-user deployment the symptom is a user who appears to get logged out at random.
+
+Run exactly one writer per profile at a time, and omit `save_changes` on everything else so parallel
+work reads the profile without racing to write it. Before you start a writer, check for an existing
+one — [Prevent concurrent profile writes](/auth/profiles#prevent-concurrent-profile-writes) has the
+query and examples.
+
+
+That check and the browser creation are separate requests. If more than one of your workers can
+start a session for the same user, hold your own lock or lease across both operations.
+
+
+## Serve many users from one browser pool
+
+Per-user traffic is bursty, so cold start is the latency your users feel.
+[Browser pools](/browsers/pools) keep warm browsers ready, but a profile attached to a pool is
+shared and read-only, which is the opposite of what you need here.
+
+Create the pool with no profile, attach the user's profile after you acquire a browser, and release
+with `reuse: false`. See
+[Per-user profiles with browser pools](/browsers/pools#per-user-profiles-with-browser-pools) for the
+full example.
+
+
+Releasing with `reuse: true` hands that user's logged-in browser to whoever acquires next. Always
+release per-user browsers with `reuse: false`.
+
+
+## Share a live view with your user
+
+You might want your user to finish a step themselves — entering a password, clearing MFA, or
+approving a prompt. [Live view](/browsers/live-view) is how you show them the browser, but treat the
+URL as a credential rather than a link.
+
+- **The live view URL grants control of that browser.** Anyone who has it can drive the session.
+- **`readOnly` is a display option, not a security boundary.** It makes the embedded view
+ non-interactive. Don't rely on it to stop a recipient from acting on the browser.
+- **Don't hand the URL to a user directly.** Serve it from your own backend behind your own
+ authorization, or embed it in a page you control, so you decide who reaches it and for how long.
+- **Deleting the browser is how you revoke access.** The URL stays usable while the browser exists,
+ independent of whether anyone is watching.
+
+Because each browser belongs to one user, a URL that leaks exposes only that user's session — which
+is another reason to keep pooled browsers on `reuse: false`.
+
+## Understand what an idle user costs
+
+A browser enters [standby](/browsers/standby) after five seconds with no CDP client, live view, or
+computer-controls call in flight. State is preserved and usage costs stop, so a per-user browser
+that's waiting on its user is cheap to leave running. You don't need to tear a session down and
+rebuild it to avoid paying for idle time.
+
+Two things to plan around:
+
+- [GPU-accelerated browsers](/browsers/gpu-acceleration) don't support standby, so an idle
+ GPU browser keeps costing you. Reach for GPU only when a workload needs it.
+- Standby starts the browser's [timeout](/browsers/termination#automatic-deletion-via-timeout)
+ countdown, and `timeout_seconds` defaults to **60**. A browser waiting on its user is deleted a
+ minute later unless you raise it. Set it to cover how long you're willing to hold a session open —
+ the maximum is 259200 (72 hours).
+
+To attribute cost per user, tag sessions at creation and break usage down by tag later.
+
+
+```typescript Typescript/Javascript
+const kernelBrowser = await kernel.browsers.create({
+ profile: { name: 'user-8f21c3', save_changes: true },
+ timeout_seconds: 1800,
+ tags: { end_user: 'user-8f21c3', workflow: 'inbox-triage' },
+});
+```
+
+```python Python
+kernel_browser = kernel.browsers.create(
+ profile={"name": "user-8f21c3", "save_changes": True},
+ timeout_seconds=1800,
+ tags={"end_user": "user-8f21c3", "workflow": "inbox-triage"},
+)
+```
+
+```go Go
+kernelBrowser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{
+ Profile: shared.BrowserProfileParam{
+ Name: kernel.String("user-8f21c3"),
+ SaveChanges: kernel.Bool(true),
+ },
+ TimeoutSeconds: kernel.Int(1800),
+ Tags: kernel.Tags{
+ "end_user": "user-8f21c3",
+ "workflow": "inbox-triage",
+ },
+})
+if err != nil {
+ panic(err)
+}
+```
+
+
+## Before you go to production
+
+- One profile per user per site, populated by your own login flow.
+- One writer per profile, with your own lock around the check and the create.
+- Pools created without a profile; attach after acquire, release with `reuse: false`.
+- Live view URLs served from your backend, never handed to a user directly.
+- A `timeout_seconds` that matches how long a user's session may stay open, and tags on every browser.
diff --git a/docs.json b/docs.json
index bb0e9df..f42d33a 100644
--- a/docs.json
+++ b/docs.json
@@ -111,6 +111,7 @@
"browsers/replays",
"browsers/viewport",
"browsers/gpu-acceleration",
+ "browsers/per-user-sessions",
{
"group": "Auth",
"pages": [