Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modellen Tools & Services
Oplossingen
Organisaties Ontwikkelaars Internet Service Providers Managed Service Providers AI-in-a-Box
Kenniscentrum
Ondersteuning Documentation Blog Downloads
Bedrijf
Over ons Onderzoek Vacatures Investeren Contact
Inloggen

Silent SSO sign-in, and the two traps

When your web app has exactly one identity provider — ScaiKey — a sign-in screen with a single Sign in with ScaiKey button is pure friction. The user has already told you what they want by opening the app. The button is worth showing only when there is something to say: you signed out, you were taken over, your session expired, sign-in failed.

Redirecting straight to ScaiKey is three lines. Doing it without introducing a redirect loop, a flash of the button, or a "why does it keep asking me" bug takes a little more care. Both traps below were live bugs in ScaiTerm.

The shape#

ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
onMount(async () => {
  if (isCallback()) {              // ?code=… — finish the exchange
    setToken(await completeLogin());
    return;
  }
  if (!token() && !hasReason() && !recentlyTried()) {
    markTried();
    await beginLogin();            // the browser leaves; nothing after this runs
    return;
  }
  // staying here: show the card, with whatever we have to explain
});

hasReason() is the whole design in one predicate. Everything that has something to tell the user suppresses the automatic redirect:

ts
1
2
const hasReason = () =>
  wasSignedOut || wasEvicted || wasIdle || wasExpired || !!errorMessage();

Those flags are read-and-clear, in sessionStorage, written by whichever code path signed the user out:

ts
1
2
3
4
5
export function consumeSignedOutFlag(): boolean {
  const v = sessionStorage.getItem(SIGNED_OUT_KEY) === "1";
  sessionStorage.removeItem(SIGNED_OUT_KEY);   // read once — it explains one visit
  return v;
}

sessionStorage, not localStorage: the message explains this visit in this tab. A flag in localStorage outlives its reason and greets someone with "you were signed out" a week later, in a tab that had nothing to do with it.

Without this, an explicit sign-out bounces straight back into the automatic sign-in and lands the user right back in the app they just left — or staring at a provider login form they never asked for.

Trap 1: the loop guard that never lets go#

You need a guard. If the app bounces back still unauthenticated — a misconfigured redirect URI, an app not yet approved, third-party cookies blocked — an unconditional redirect ping-pongs forever, and the user never sees the error that would explain it.

The obvious guard is a flag: this tab has tried once, don't try again.

ts
1
2
// Don't do this.
const tried = () => sessionStorage.getItem(KEY) === "1";

It works, and then it keeps working long after the loop it was guarding against. Any later arrival in that tab — a location.replace, a Back button, an abandoned provider page, a session that expired hours later — hits a flag set once and never cleared, and drops the user on the button you were trying to remove. That is not a loop; that is a user coming back.

The evidence of a loop is not "an attempt happened", it is "an attempt happened just now". A redirect loop completes in well under a second. Time-box it:

ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const COOLDOWN_MS = 30_000;

const recentlyTried = () => {
  try {
    const at = Number(sessionStorage.getItem(KEY) || 0);
    return at > 0 && Date.now() - at < COOLDOWN_MS;
  } catch {
    return true;              // storage blocked → never auto-redirect
  }
};

const markTried = () => {
  try { sessionStorage.setItem(KEY, String(Date.now())); } catch { /* private mode */ }
};

A real loop still stops after one bounce, because a loop is by definition fast. A user returning a minute later is signed in silently.

Note the catch returning true. If storage is unavailable you cannot detect a loop at all, so the safe answer is "don't redirect automatically" — the user gets a button that works, rather than a tab that spins.

Trap 2: the button that appears for one frame#

onMount is asynchronous. The component renders before it runs. So on first paint, with no token and no decision yet made, the login card renders — and is replaced a frame or two later by the redirect.

Users see a flicker and read it as a broken app. Worse, the button is real while it is on screen: click it in that window and you start a second login, racing the automatic one.

Fix it with an explicit third state. Not "signed in / signed out" but "signed in / signed out / still deciding":

ts
1
2
3
4
const [deciding, setDeciding] = createSignal(!isCallback());

// …in onMount, at every point where the decision is final:
setDeciding(false);
tsx
1
2
3
4
5
6
<Show when={redirecting() || deciding()}>
  <Spinner label={redirecting() ? "Signing you in…" : "Checking your session…"} />
</Show>
<div class="card" classList={{ hidden: redirecting() || deciding() }}>
  
</div>

The card cannot render until you know it should. This generalises past login: any UI whose content depends on an async decision made after mount has the same hole, and the same fix.

Clean up the callback URL#

The redirect lands on your registered redirect_uri/auth/callback — and without help it stays in the address bar for the rest of the session. It is plumbing, and it is a poor thing to bookmark or paste to a colleague.

After the code exchange, scrub the query and the path:

ts
1
2
const root = window.location.pathname.replace(/\/auth\/callback\/?$/, "") || "/";
history.replaceState({}, "", root);

Stripping only the suffix keeps a sub-path deployment (/app/auth/callback) working. Do it after the exchange, never before — the code and state are needed first, and replaceState does not reload, so there is no race.

What good looks like#

Situation What the user sees
First visit, valid SSO session A brief "Checking your session…", then the app
First visit, no SSO session Straight to the provider's login form
Signed out deliberately The card: "You're signed out. Sign in whenever."
Idle timeout / expiry / take-over The card, saying which of those happened
Sign-in genuinely broken The card, with the error, and a button that works

Checklist#

  • One predicate (hasReason()) decides card-vs-redirect, and every sign-out path sets a flag it reads.
  • Flags live in sessionStorage and are read-and-clear.
  • The loop guard is time-boxed, not once-per-tab.
  • Storage unavailable → no automatic redirect.
  • A deciding state keeps the card off screen until the decision is made.
  • The callback path is scrubbed from the URL after the exchange.
  • Every reason the user can be signed out has its own sentence. "Session ended" tells them nothing about whether to worry.

Why this is worth the care#

Sign-in is the first thing every user does and the last thing anyone tests by hand. Each of these bugs is invisible to the person who wrote the flow — you already have a session, so you never see the card — and obvious to everyone else, every day. If your app has one IdP, the sign-in screen should be something users encounter when something went wrong, not a toll gate they pay every morning.

Updated 2026-09-02 12:39:30 View source (.md) rev 1