When a user signs into another app: surviving a shared SSO session
Every ScaiLabs app shares one ScaiKey SSO session per browser. Anyone with two accounts — a personal one and an admin or service account, which is most operators — will eventually sign into app B as the other account while app A is open. Here is what happens then, why it looks like a bug, and what an app can do about it.
This is written from a real incident, timestamps and all.
What it looks like#
An operator working in a terminal is thrown out mid-session:
Your session expired and had to be renewed. Your running sessions are still there — sign in to pick them up.
They sign in. Their workspace is empty. Every long-running session they had is gone from the list. Nothing in the UI explains it, and the obvious conclusion — "the server dropped my sessions" — is wrong in every particular.
What actually happened#
Reconstructed from our own logs, in order:
| Time | Event |
|---|---|
| 06:24 – 22:37 | twelve token refreshes, all 200 OK, one per hour, all clean |
| 22:26 | the operator signs into another ScaiLabs app as a different account, in the same browser |
| 23:37 | the next hourly refresh returns 400 invalid_grant — "Refresh token has been revoked" |
| 23:37 | the app ejects to the sign-in screen |
| 23:4x | they sign in — as the account the SSO session now holds — and see zero sessions |
Two independent things went wrong, and conflating them is what makes this hard to debug:
1. The refresh token was revoked. Signing into the second app moved the shared SSO session to the other identity, and the identity provider revoked the previous refresh-token family. From our side this is indistinguishable from a stolen token, which is exactly why the provider treats it seriously.
2. The sessions were never lost. They were owned by the first account. Session ownership is per-subject and exact, so the second account correctly saw none of them. The backend had been up for three days with zero restarts and was still holding all eight, none orphaned. The message promising "your running sessions are still there" was true — for an identity the user was no longer using.
Three things worth fixing, in order of value#
1. A revoked refresh token is not the end of the session#
This is the one that matters, and it is entirely within your app.
A refresh token being refused says nothing about the access token you already hold. That token is still valid, typically for up to an hour. Ejecting the moment the provider says no throws someone out of a terminal mid-command for a deadline that has not arrived yet.
Split the two states:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
Then show the deadline instead of enforcing it early:
Sign-in ending in 47 min. Your sign-in was ended elsewhere (signing into another ScaiLabs app with a different account does this). Your terminals keep running — sign in again to keep using them. [Sign in again]
A bar, not a toast: it is a deadline to act on, not a message that passes. The user finishes the command they were typing and re-authenticates when it suits them. Same outcome, no lost work.
Word it for the likely cause, not the scary one. A revoked token can mean theft. Overwhelmingly it means the person signed into something else. Say the useful thing; the security story does not change either way, because the session still ends.
2. Say whose sessions you are showing#
An empty list after an account switch is indistinguishable from an empty list after a catastrophe. One label removes the ambiguity permanently:
1 2 | |
And when the identity changes between visits — remember the last one in
localStorage, compare on sign-in — say so once:
Signed in as service@scailabs.ai — previously marcel@scailabs.ai. Sessions opened by marcel@scailabs.ai stay with that account and are not listed here.
That sentence is the entire incident, answered before it is asked.
3. Don't promise what an account switch cannot deliver#
"Your running sessions are still there — sign in to pick them up" becomes a lie the moment the user signs in as someone else. It costs four words to be exact: sign in with the same account to pick them up.
What NOT to do#
Do not let the new account adopt the old account's sessions. Ownership is a
security property, not an inconvenience. A super-admin signing in must not
silently inherit a colleague's shells; if fleet-wide visibility is needed, it is
a separate, audited, explicitly-labelled feature — not a side effect of
list(opener=me) being loose about who "me" is.
Do not paper over it with a longer refresh lifetime. The revocation is not a timing problem.
Do not retry the refresh. It will fail identically, and on a provider with reuse detection, repeatedly presenting a revoked token is the exact signature of an attack.
The question worth asking your identity provider#
Reuse detection exists to catch a stolen token: someone replaying a credential the legitimate client already rotated. A person deliberately signing into a second app with a second account is not that.
If your provider revokes the previous family on an account switch, ask whether those two cases can be distinguished — a switch is a supersession, not a breach. Narrowing it means an operator signing into another tool no longer destroys their session in the first one, which removes the incident rather than managing it.
We are asking; the answer will land here when we have it.
Checklist#
- A failed refresh distinguishes transient (5xx, 429 — keep the session, retry) from terminal (4xx — the session is finite).
- A terminal failure does not clear the session; the access token is allowed to live out its life.
- The end of the session is enforced in exactly one place: when the access token actually expires.
- A visible countdown with a re-authenticate button, not a silent ejection.
- Every list scoped to an identity says which identity.
- An identity change between visits is announced once, naming both.
- No message promises session continuity without saying "same account".