Skip to content
Alexandre GaubertAlexandre Gaubert
September 16, 2026 · 8 min read

Two Logins, One Tab, One Cache Key

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.

Stay ahead in CI/CD

Blog posts, release news, and automation tips straight in your inbox.

Recommended posts

We deleted a 63 MB GeoIP database and replaced it with a 64-byte signature
September 18, 2026·18 min read

We deleted a 63 MB GeoIP database and replaced it with a 64-byte signature

Our device fingerprint never refused a login, so we bound sessions to a WebCrypto key the browser cannot export. What broke, and why Chrome's DBSC should retire most of it.

Mehdi AbaakoukMehdi Abaakouk
We Stopped Our Config Builder From Destroying Your YAML, Then Found a Worse Bug Next to It
July 29, 2026·8 min read

We Stopped Our Config Builder From Destroying Your YAML, Then Found a Worse Bug Next to It

Our no-code config builder used to strip comments and anchors out of your .mergify.yml on every commit. Fixing it meant patching the document in place instead of re-dumping it, and an audit of the code next door turned up a parser bug that had been silently inverting a security condition in production.

Alexandre GaubertAlexandre Gaubert
The Native TypeScript Compiler Cut Our Typecheck From 13s to 3.5s. The Only Hard Part Was ESLint.
July 27, 2026·7 min read

The Native TypeScript Compiler Cut Our Typecheck From 13s to 3.5s. The Only Hard Part Was ESLint.

We swapped in TypeScript 7's native Go compiler and our dashboard typecheck went from 13s to 3.5s. The compiler was nearly a drop-in. The real work was keeping typescript-eslint alive when native 7.0 ships no importable compiler API.

Thomas BerdyThomas Berdy