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#
1 2 3 4 5 6 7 8 9 10 11 12 | |
hasReason() is the whole design in one predicate. Everything that has
something to tell the user suppresses the automatic redirect:
1 2 | |
Those flags are read-and-clear, in sessionStorage, written by whichever
code path signed the user out:
1 2 3 4 5 | |
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.
1 2 | |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
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":
1 2 3 4 | |
1 2 3 4 5 6 | |
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:
1 2 | |
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
sessionStorageand are read-and-clear. - The loop guard is time-boxed, not once-per-tab.
- Storage unavailable → no automatic redirect.
- A
decidingstate 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.