The problem
TTMI runs several internal web apps for ~500 employees across 4 brands, and they share one identity source. I wrote the client-side pieces described here. The receiving apps and the server-side checks live in other codebases.
There were three gaps.
Staff who had left the company could keep using a tab that was already open until their token expired.
Moving from one internal app to a sibling app meant signing in again.
A new finance area needed its own roles and data scopes, and screens a user was not allowed to see still started their data queries in the background.
What I did
A status check inside the request interceptor
Before sending a regular API request, the client checks the user’s employment status. A positive result is cached briefly, and concurrent checks share one in-flight request. Focusing the tab, switching back to it, or a session change in another tab forces a fresh check. If the user has left, the client wipes the session and redirects to sign-in.
Checking on every request would add a round trip to everything. Caching leaves a short exposure window, which I documented. I accepted it because the server stays the authority, and this guard exists to close an open tab quickly.
Fail closed on unknown status, stay open on network errors
An unknown or departed status ends the session. A failed network call does not, because a flaky connection is no evidence that someone left, and logging people out on every blip would be worse for everyone. The status check also goes through a separate HTTP path, so the interceptor cannot call itself in a loop. I wrote this failure policy down before writing the code.
Sign-in hand-over between sibling apps without tokens in URLs
The source app opens an allow-listed target app. The two windows complete a messaging handshake where the target announces it is ready with a one-time nonce. The source checks the message origin, the sending window and the nonce, re-checks that its own session is still live, and only then hands the session over. Nothing sensitive appears in a URL, so it cannot leak through history or logs. Timeouts and blocked popups show a clear message, and direct sign-in stays available as a fallback.
The trade-off is that revoking the source session also ends any session derived from it. I accepted that and documented it, since it matches how revocation should behave.
Permission gates that fail closed
A route the user cannot access never mounts its data queries, so no request goes out for data the user should not see. Navigation hides the entry, and an elevated role in one area does not bypass another area’s permissions. These checks mirror server authorization for a cleaner experience. They never replace it.
Result
The guards are in production for all staff roles. Departed staff lose access on their next activity, without waiting for token expiry. Users move between sibling apps in one click, and no token ever appears in a URL. Denied screens show a “no access” state without loading data.
Contract tests in Vitest cover the denied, error and boundary cases of these guards.
What I’d do differently
I would agree the failure policy with the backend owners earlier, as a shared written contract, because the client and server versions of “what fails closed” have to match. I would also add a small end-to-end test across two real app origins, since unit tests can only simulate the window handshake.