ProxDocs

Cookies and sessions

A logged-in proxied site is a cookie that survived. This page is about where that cookie lives, who owns it, and the three ways people accidentally destroy it.

Scramjet 2.x handles almost all of this for you. The value of knowing the mechanism is that when a login doesn't stick, you can tell which layer broke.


Why cookies can't just work

A proxied page runs on your origin. From the browser's point of view there is one site, yours, and every proxied page is part of it.

If Scramjet let document.cookie reach the real browser cookie store, every proxied site would share one jar scoped to your domain. Logging into one site would leak its session cookie to the next site you visited, and two sites using the same cookie name would overwrite each other. It would also mean a proxied page could read your own app's cookies.

So Scramjet doesn't use the browser's cookie store for proxied content. It keeps its own.


The CookieJar

The controller owns a single CookieJar:

controller.cookieJar;

One jar, all frames, keyed by the destination origin rather than yours. Cookies for github.com and cookies for google.com stay separate the way they would in a real browser, because the jar records which origin each one came from.

Reads and writes flow through it automatically:

  • A Set-Cookie header on a proxied response goes into the jar, not the browser.
  • document.cookie inside a proxied page is rewritten to read and write the jar.
  • Outgoing requests get a Cookie header built from the jar for that destination.

Nothing in your application code has to participate. If you are writing cookie handling in a plugin, you are almost certainly reimplementing this.


Persistence

The jar is written to IndexedDB, in a database named __scramjet_controller, store state, under the key cookies.

controller.wait() waits for the jar. The constructor builds one Promise.all over three things, the worker handshake, the wasm fetch, and loadSavedCookies(true), and wait() resolves on that. So once wait() comes back, saved cookies are already in memory.

Requests reload it defensively anyway, and the controller holds each proxied request until that finishes, so even a frame you created too early issues its first request with the right cookies. You can't really race this one, which is unusual for this codebase and worth enjoying.

The persisted record is small:

{ updatedAt: 1735689600001, cookies: "<serialised jar>" }

updatedAt is a logical clock, not a wall clock. Every write takes Math.max(Date.now(), currentUpdatedAt + 1, existingUpdatedAt + 1), so it always moves forward even if two tabs write in the same millisecond or the system clock jumps backwards. Conflict resolution is last-write-wins on that number.

IndexedDB is the browser's asynchronous structured storage, bigger and slower than localStorage, and the only sane place for something like this. MDN has a usable overview.


Cross-tab sync

Two real browser tabs of your proxy each get their own Controller, each with its own in-memory jar. They stay in step over a BroadcastChannel named __scramjet_controller_channel.

The protocol is one message: { updatedAt }. When a controller persists cookies, it broadcasts the new timestamp. Other controllers compare it against their own and re-read from IndexedDB if it is newer.

Tab A                          Tab B
  │ login → Set-Cookie
  │ jar updated
  │ write IndexedDB
  │ broadcast { updatedAt }  ──────►
                                 │ newer? yes
                                 │ re-read IndexedDB
                                 │ jar updated

Only the timestamp crosses the channel, never cookie values. The reload is what transfers state, which keeps the channel cheap and means a tab that missed a message self-corrects on the next one.

This is eventually consistent, not instant. Log in on tab A and tab B becomes logged in shortly after, not synchronously. A page mid-request in tab B may still complete with the old jar. Almost nobody needs to engineer around this.


The service worker

The service worker sees requests too, and it needs the same cookies. It doesn't get its own jar; the controller pushes to it over the message channel established at construction, and the worker acknowledges when applied.

Practical consequence: the controller is the source of truth, and it lives in the page. If the service worker is alive but no page holds a controller, which happens when the worker gets revived independently, cookie state isn't available to it until a controller reconnects. This is why the controller guards service-worker revival for the first few seconds after boot instead of immediately rebuilding its message port.


What breaks logins

Three causes, most likely first.

Frames created before controller.wait() resolved

The jar loads during boot. Create a frame first and its first requests carry no cookies.

const controller = new Controller({ serviceworker, transport });
await controller.wait();

const frame = controller.createFrame(iframe, { plugins });

The symptom is specific and recognizable: the first page load after a refresh is logged out, and everything after it is fine.

A second controller

Two controllers means two jars. They will fight over IndexedDB, and each one's frames see whichever state that controller last read. Symptoms look random and depend on tab focus.

One controller per page. If you are creating one per tab in a tabbed UI, that is the bug. Tabs share one controller and get one frame each.

Clearing site data

Your settings, your history, and the proxied sites' cookies are all on your origin. A "clear everything" button that wipes IndexedDB logs the user out of every proxied site.

That is often correct, since it is what the button says. Scope it deliberately, and say what it does:

const clearBrowsingData = async () => {
	await indexedDB.deleteDatabase("__scramjet_controller");
	location.reload();
};

Reload afterwards. The live controller still holds the old jar in memory and will write it straight back.


Reading the jar yourself

Rare, but legitimate: a cookie manager UI, or debugging why a site thinks you are logged out.

const jar = controller.cookieJar;

Treat it as opaque and read-only. Writing to it directly bypasses the persist and broadcast steps, so your change is invisible to the service worker and to other tabs, and disappears on the next load from IndexedDB.

If you need cookies to survive, let them get there the normal way: through a response the proxy handled.


What this doesn't give you

Separate identities per tab. One jar, one controller, one set of logins. Two tabs can't be signed into the same site as different users. That needs separate controllers on separate origins, which is a much larger design.

Isolation from your own app. The jar isn't the browser's cookie store, so proxied sites can't read your app's cookies through document.cookie. But it lives in IndexedDB on your origin, and a proxied page runs on your origin, so a hostile page can reach the database directly. See Practices worth knowing.

Anything resembling privacy from the operator. Whoever runs the proxy serves the client. See Wisp vs Bare.


Where to go next

Profile Views