Two Logins, One Tab, One Cache Key

One sessionStorage key for every login meant the next person on a tab briefly saw the previous account's profile. Naming the cache after the login fixed it, at a price.
sessionStorage is not scoped to your session. It’s scoped to the tab, and a tab outlives a session easily. It can hold two of them. Two different people, if you’re unlucky.
We were unlucky.
Our dashboard keeps a copy of what it fetched, so a reload has something to show while the real requests go out. React Query does the saving, sessionStorage holds it, and the whole thing is filed under one name:
REACT_QUERY_OFFLINE_CACHE_00000000000000000011
One name. Forever. For everyone who has ever signed in on that tab.
So Alice signs in and the dashboard writes her profile and her organizations under that name. Her session dies. Bob signs in, same tab, and the dashboard reads the only name it knows. For half a second, Bob is looking at Alice.
Bob sees a cached profile: Alice’s name, avatar and organizations, replaced as soon as the network answers with his own, which on a bad day for GitHub is not half a second. Nobody gets into anyone’s account. Still not something you want to explain to a customer.
The good deed that woke it up
This sat there harmless for years, and what finally armed it was us fixing something else.
Our boot screen used to ask whether the last request had succeeded. Reasonable question, wrong one: a React Query query reports error as soon as a refetch gives up, even when it’s still holding perfectly good data. So during the GitHub outage of August 17th, a dashboard that had everything it needed to render sat on a loading spinner and stayed there. A customer put it plainly: “Mergify doesn’t load, constantly on loading screen.”
So we changed the question: is there anything to render? Now a bad day at GitHub gets you the app instead of a spinner.
That change turned a dormant collision into a visible one. Before, the stale cache was never on screen, because nothing rendered from it. Now the app renders from it first and asks questions later. We shipped a resilience feature and, for a few hours, a data leak with it.
The obvious fix is a trap
Catch the moment the session ends. A 401 means signed out, so empty the cache. Twenty lines, and it looks airtight.
Not every 401 we receive means the session is gone. Some of our endpoints return one for things a token isn’t allowed to do, so some of our queries treat a 401 as a normal answer and render an empty list. Now those benign answers wipe the cache of a live session, and you’re maintaining a list of callers allowed to get a 401.
The guard you add needs its own guard. You can’t clear on every 401 or a failing session clears on every retry, so you latch it. But the component holding that latch lives above the login screen, so it survives the session that ended. When the next session starts, the latch is still closed, and that session’s sign-out clears nothing.
Two patches deep to keep one heuristic alive, and each patch needed its own exception. We stopped there.
There is a better moment, and we use it: clear when a session is established, at the OAuth callback, where nothing has to be inferred. It works, but it’s one more call site to remember on every path that can start a session.
The browser doesn’t know what a login is
The real question is smaller and harder. At boot, what value do I have that changes exactly when the person changes?
Not the user id. At the moment the cache is restored, nobody knows who is signed in. That’s the entire point of the request about to go out.
And not anything the browser offers:
| Scope | Dies when | |
|---|---|---|
localStorage |
origin | site data is cleared or evicted |
sessionStorage |
one tab (copied when the tab is duplicated) | the tab closes |
| IndexedDB | origin | site data is cleared or evicted |
None of them is “one login”, because the browser has no idea what a login is. That’s your idea. Your app invented sessions, gave them a lifetime, and then stored their data in a box named after something else: the browser’s session, which usually lasts longer.
The name sessionStorage promises a scope it does not have, and the gap between the browser’s session and yours is wide enough for two accounts to meet.
Nobody can hand you a login-scoped store. You have to build the scope yourself, and the way you build it is by naming the data after the thing it belongs to.
We already had the right value, sitting in our request-signing setup, which exists for a different reason. At every login, the browser creates a keypair it cannot export, and the server issues an id that ties requests to that keypair. New login, new id. It changes at exactly the right moment, which is the only property I needed. No such mechanism? A random string generated at sign-in does the same job, as long as every sign-in path mints it.
So the cache borrows it to name its entry, through a persister that can wait for the name. The first version was four lines:
const scopedKey = async (key: string) => {
const tag = await getSessionScopeTag();
return `${key}.${tag}`;
};
Two logins in the same tab now write two different names:
Alice: REACT_QUERY_OFFLINE_CACHE_00000000000000000011.a1b2c3d4
Bob: REACT_QUERY_OFFLINE_CACHE_00000000000000000011.f9e8d7c6
Bob reads his own, which is empty. No code has to notice that the person changed.
sequenceDiagram
participant Tab as One tab
participant SS as sessionStorage
Note over Tab,SS: One key for everyone
Tab->>SS: Alice signs in, write CACHE_11
Tab->>Tab: Alice's session dies
Tab->>SS: Bob signs in, read CACHE_11
SS-->>Tab: Alice's profile and orgs
Note over Tab,SS: One key per login
Tab->>SS: Alice signs in, write CACHE_11.a1b2c3d4
Tab->>Tab: Alice's session dies
Tab->>SS: Bob signs in, read CACHE_11.f9e8d7c6
SS-->>Tab: null
This doesn’t remove Alice’s data from the tab. Object.keys(sessionStorage) lists it, and any script running on our origin reads it in one line. This was never a defense against an attacker who is already executing in the page. It stops our own code from reaching for the wrong drawer, which is what was actually happening.
One case it can’t fix: when our staff impersonate a customer, the server deliberately keeps the same session, so both share one id and one key. Namespacing cannot separate two people the server considers one. That path clears the tab on the way in, and now on the way out too.
The four lines came with a bill
Reading that tag means reading IndexedDB, and IndexedDB is async. So there’s now a gap between “save the cache” and “here’s the name to save it under”, and a session change fits inside it.
A write that starts under Alice resolves its name after Bob has signed in, and files Alice’s snapshot under Bob’s key. The constant never had that problem. A constant needs no lookup.
So there’s a counter now, bumped on every session change, and any read or write whose name lookup spans a bump gets discarded. Then eviction: a tab that had a binding and lost it looks exactly like a tab that never had one, and only one of those can safely use the unsuffixed key, so the tab has to remember which it was. And if a login can neither write its own binding nor erase the previous one, because IndexedDB is refusing both, the tab gives up on naming anything at all and goes without a cache until it’s reloaded.
None of that is in the four lines. All of it came out of review, and the pull request ended up six times the size it was the day I thought it was finished.
Naming your data after its owner removes the detection problem. Looking up that name asynchronously creates a new one: the name can be resolved against the wrong session. It’s still cheaper than detecting sign-outs. It isn’t free.
Break it on purpose
The tests write as one login, read as another, and expect nothing back.
That test passes against anything. An empty sessionStorage returns null for the reason you meant and for every reason you didn’t, so a green run proves the storage was empty, not that your isolation works.
So delete the mechanism and watch:
const scopedKey = async (key: string) => {
return key;
};
Exactly two tests go red. The other two stay green, which is how you know those two were never testing this in the first place. It takes a minute. I no longer trust an isolation test I haven’t broken by hand, and isolation is the kind of thing that fails silently for months.
Go look at your key
If you persist a query cache in a browser where more than one person can sign in, go find out what your key is made of right now. If it’s a constant, both accounts share it, and the only thing between them is whether your app paints before the network answers. Ours had been a constant through all eleven versions its numeric suffix counts, and nobody had looked.
Before you write code that watches for the instant your data goes stale, check whether you can name the data after its owner instead. Then there’s nothing to get right at three in the morning.


