---
summary: "Redirect straight to ScaiKey instead of showing a sign-in button \u2014\
  \ and avoid the redirect-loop guard and first-paint flash that come with it."
audience: front-end engineers on ScaiLabs web assets
title: Silent SSO sign-in, and the two traps
path: tutorials/silent-sso-sign-in
status: published
---

# 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
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
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
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
// 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
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
const [deciding, setDeciding] = createSignal(!isCallback());

// …in onMount, at every point where the decision is final:
setDeciding(false);
```

```tsx
<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
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.
