Skip to content
C.W.K.
Lesson 01 of 05 · published

An Identity Is a Store

~15 min · webkit, identities, isolation, data-store

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Why Dad kept several browsers open

Before Voyager, holding two accounts of one site meant holding two browsers: one signed in as the first account, another as the second, and Dad switching windows to switch who he was. Voyager's house sign lists it among the frictions the browser exists to remove. He "runs several browsers at once to hold two accounts of one site."

A browser keeps a site's sign-in in cookies and site storage, and a browser profile has one jar of them. Two accounts of one site need two jars. Voyager calls a jar an identity, and architecture invariant 7 states the rule in one line: "An identity is an isolated store, and a tab has exactly one."

Three small types

The parent target holds identities as plain values in Sources/VoyagerCore/Identity.swift. Its header ends with a sentence worth keeping: "Nothing here is a credential."

  • IdentityID wraps a UUID. It encodes as the UUID's string and refuses to decode anything that is not one.
  • Identity is that id plus a name and a colour, written #RRGGBB. The default colour is a grey, #8A8F98.
  • IdentityBook is the file identities.json: the list, a schema version, and the default identity a tab gets when nothing names one. A first run mints a single identity called "Main", so its store starts empty.

One id is reserved. IdentityID.privateSession is a fixed UUID that belongs to no book. Incognito uses it, and lesson 3 opens it.

The id is the store's name

The engine child turns an id into a WebKit store with one call, WKWebsiteDataStore(forIdentifier:). Apple's documentation is plain about it: if no store exists for that identifier, the system creates one, and the method is how you "get a data store for a profile". The engine keeps every store it has made in a dictionary, so all pages of one identity share one object, and every WKWebViewConfiguration it builds sets websiteDataStore to the store of that page's identity.

That is the whole isolation mechanism. Voyager writes no cookie partitioning, no request filter and no profile directory of its own. Cookies, local storage and the rest of a site's data live wherever WebKit keeps that store, and a page whose configuration names another store has no way to reach them.

QuestionWebKitChromium
How does a tab get its own accounts?A public API: WKWebsiteDataStore(forIdentifier:), macOS 14 and iOS 17Its own request context, which is a whole profile, so extensions and history split too; logins alone took a patch set carried in a fork
Who already ships it?Orion, on the same data-store APIBrave, which carried its patch set for eight months before it landed

That row of the engine evaluation is one of the reasons Voyager runs on WebKit (track 2 tells the rest). Firefox reaches the same place with its containers.

Two screenshots of the same Voyager window side by side, each showing creativeworksofknowledge.com with a different tab in front: on the left the first tab, signed in as Dad with the ADMIN badge; on the right the second tab, signed in as a separate Pippa account with no admin badge. Each tab carries a coloured identity bar at its left edge.
One site, two tabs, two identities in one window: Dad's admin session on the left, a second account signed in as Pippa on the right, each tab marked by the identity bar at its left edge.

Two tabs of one site, two identities, one window. Each tab is signed in as itself because each web view was born with a different store.

The test that proves it

The real-WebKit suite carries twoIdentitiesShareNothing. It opens three pages, two in identity A and one in B. Page A writes a cookie and a local-storage value. The second page of A must see both; it is another web content process, so the test polls for up to three seconds (60 tries, 50 ms apart). Page B must see nothing, null|. Then each identity's store is removed, with retries, because WebKit refuses to remove a store while a web view of it still exists. The house sign keeps that as a gotcha: "An identity's data store is removed only when no web view of it is left."

The same check is case W7 of the WebKit gate: "Two accounts of one site in two tabs of one window; a pop-up stays in its tab's identity." The gate cites this suite as its evidence. It is a test on synthetic pages; the row records no sign-in of two real accounts, and I would not claim one for it.

When the book cannot be read

Because the id is the only name a store has, losing an id loses the way back to its store. Voyager guards that at launch. If identities.json cannot be read, the run starts with a fresh book in memory, says so in a notice ("This run uses a fresh one and leaves the file alone"), and never writes to that file for the rest of the run. The damaged file keeps the old ids for whoever repairs it, and IdentityBook's own comment calls the file "Safe to rebuild by hand."

Principle: Give the isolation a name you own. Voyager never asks WebKit which store a page uses. The tab carries the id, and the id is the store.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: one store per identity, named by its id (verbatim, lines 78-93)·swift
    /// One data store per identity, named by the identity's id: its cookies
    /// and site storage are in no other identity's reach.
    func store(for identity: IdentityID) -> WKWebsiteDataStore {
        if let store = stores[identity] { return store }
        // The private identity's store lives in memory for this run only.
        let store = identity == .privateSession ? WKWebsiteDataStore.nonPersistent() : WKWebsiteDataStore(forIdentifier: identity.raw)
        stores[identity] = store
        if identity != .privateSession {
            // The cookie store holds its observers weakly; the engine keeps
            // the watcher, and the watcher keeps no store.
            let watcher = CookieWatcher(identity: identity, engine: self)
            store.httpCookieStore.add(watcher)
            cookieWatchers[identity] = watcher
        }
        return store
    }
cwkVoyager, Tests/VoyagerWebKitTests/WebKitPageTests.swift: two identities share nothing, on real WebKit (verbatim, lines 384-410)·swift
    @Test func twoIdentitiesShareNothing() async throws {
        let a = IdentityID(), b = IdentityID()

        // The pages live inside this function: a store can be removed only
        // once no web view of its identity exists.
        func exercise() async throws {
            let pageA = makePage(identity: a)
            let pageB = makePage(identity: b)
            let pageA2 = makePage(identity: a)
            for page in [pageA, pageB, pageA2] { await secure(page, "<p>store</p>") }

            let wrote = await js(pageA, "localStorage.setItem('who', 'identity-a'); document.cookie = 'who=identity-a; path=/'; return localStorage.getItem('who') + '|' + document.cookie")
            #expect(wrote == "identity-a|who=identity-a")
            // A second page of the same identity sees both; it is another
            // process, so give the store a moment to hand them over.
            var sameIdentity = ""
            for _ in 0..<60 {
                sameIdentity = await js(pageA2, "return String(localStorage.getItem('who')) + '|' + document.cookie")
                if sameIdentity == "identity-a|who=identity-a" { break }
                try await Task.sleep(for: .milliseconds(50))
            }
            #expect(sameIdentity == "identity-a|who=identity-a")
            let otherIdentity = await js(pageB, "return String(localStorage.getItem('who')) + '|' + document.cookie")
            #expect(otherIdentity == "null|")
            for page in [pageA, pageB, pageA2] { page.close() }
        }
        try await exercise()

External links

Exercise

In a small macOS or iOS app, make two WKWebView instances whose configurations use WKWebsiteDataStore(forIdentifier:) with two different UUIDs that you save to a file. Load https://example.com in both. In the first, set a cookie with document.cookie and a value with localStorage.setItem; in the second, read both back. Then open a third web view with the first UUID and confirm it sees the values. Finally close every view of the first identity and remove its store with WKWebsiteDataStore.remove(forIdentifier:).
Hint
A second view of the same store runs in another process, so read in a short polling loop rather than once. If removal throws because the store is in use, something still holds a web view of it: release every view before you remove.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.