Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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:

ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// The refresh chain is dead. The session is finite — but not over.
let refreshDead: string | null = null;

async function refresh() {
  // …
  if (!res.ok) {
    if (res.status >= 500 || res.status === 429) return null;  // transient: retry later
    refreshDead = explain(await res.json());                   // terminal: mark, don't eject
    return null;
  }
}

async function freshToken() {
  const tok = getToken(), exp = accessExpiresAt();
  if (tok && Date.now() < exp - 60_000) return tok;
  // The session ends HERE — when the token we were nursing actually runs out.
  if (refreshDead && tok && Date.now() >= exp) { endSession(); return null; }
  if (!refreshDead) return (await refresh()) ?? tok;
  return tok;
}

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:

tsql
1
2
RUNNING                                    service@scailabs.ai
no sessions running for service@scailabs.ai.

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".
Updated 2026-09-05 23:50:40 View source (.md) rev 1