Daniel Nguyen
← All work

Session and access control across internal web apps

Client-side guards so revoked staff lose access on their next action, users move between sibling apps in one click, and denied screens never load data.

TTMI
Revoked on next actionDeparted staff no longer wait for expiry
Area
Internal web apps
When
2025–2026
My role
Frontend developer (client side)
  • TypeScript
  • React
  • TanStack Router
  • TanStack Query
  • Axios
  • Vitest

How it fits together

Session and access control across internal web appsClient-side guards: a status check on outgoing requests, a nonce handshake for sign-in between sibling apps, and a gate that stops denied screens from loading data.App 1 browser tabpersonRequest interceptorcheck or alarmNonce handshakecheck or alarmPermission gatecheck or alarmAuth + profile APIserviceSibling app tabpersonScreen data queriesserviceoutgoing API requeststatus check, cachedopen allow-listed appready + noncesession after checksnavigate to routemount only if allowed
Client-side guards: a status check on outgoing requests, a nonce handshake for sign-in between sibling apps, and a gate that stops denied screens from loading data.
  • Person
  • Check or alarm
  • Service

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.