DEV Community

Prathamesh Phadke
Prathamesh Phadke

Posted on

Multi-Tab Session Sync in a PKCE SPA with `okta-auth-js`

The problem

Every tab runs its own OktaAuth instance with its own timers and its own in-memory auth state, but they all share one localStorage. Tokens become shared mutable state with concurrent writers and no transaction boundary.

What the SDK gives you

With tokenManager: { storage: localStorage, syncStorage: true, autoRenew: true }:

  • a storage event listener that turns another tab's token writes into added / removed events, which refresh authState
  • per-key expiry timers that trigger renewal automatically

What it does not give you

  • No mutual exclusion on renewal — every tab renews at the same time. Note expireEarlySeconds is ignored outside localhost, so it can't be used to stagger them.
  • No coordination of the login redirect — each tab can start its own authorize round trip and return with its own single-use code.
  • No protection of the PKCE transaction — the code_verifier sits in storage unlocked; any tab that clears storage destroys another tab's in-flight login.
  • No leader concept — election is yours to build.

Two behaviours worth knowing: authState is null on the redirect callback page (the SDK deliberately skips the state update there), and same-document writes fire no storage event — so two SDK instances in one tab never see each other.

Failure modes

Symptom Cause
invalid_grant on a refresh token two tabs redeemed the same rotating token, outside the grace window
Unable to retrieve OAuth redirect params from storage a tab cleared storage during another tab's login
invalid_grant on an authorization code code replayed, or exchanged after its 60-second lifetime
A downstream system gets a stale token the token was read from a render-time snapshot instead of storage
All tabs log out when one errors a localStorage.clear() propagated through syncStorage
OAuth flow timed out silent renewal fell back to the hidden-iframe flow and nothing answered

Design rules

  1. One OktaAuth instance per document. Construct once, export it.
  2. Elect a renewal leader with the Web Locks API — atomic and auto-released on crash. Re-check inside the lock before renewing; a previous holder may already have done it.
  3. Serialize the login redirect too. Followers wait for tokens to appear via the storage event.
  4. Never clear whole-origin storage. Remove only your own namespaced keys, and never while a PKCE transaction exists.
  5. Read tokens at the point of use (getIdToken() / getAccessToken()), never from a cached render snapshot — there is always a gap between the storage write and the re-render.
  6. Publish everything you write. Route writes through tokenManager.setTokens() so events fire; returning a token read straight from storage leaves authState stale.
  7. Set the rotation grace period above your worst-case lock handoff.
  8. Treat invalid_grant as "someone else rotated it" first — re-read storage once before declaring failure, and re-authenticate rather than deleting the shared refresh token.
  9. Renew only while the document is visible. Frozen or discarded tabs stall requests and then fire overdue timers on resume.
  10. Keep the callback page independent of app state, flags and data. Exchange the code on first paint — codes expire in 60 seconds.

Identity-provider settings

  • Rotation grace period ≥ worst-case cross-tab handoff
  • Refresh inactivity window ≥ 3 × access token lifetime
  • Refresh absolute lifetime greater than the IdP session lifetime, or refresh tokens buy nothing

Consequence to decide deliberately: once refresh tokens are in use, renewals stop touching the IdP, so the app session outlives the SSO session. Also, enabling the refresh grant affects new logins only — existing sessions renew with the scopes already on their tokens.

Test matrix

"Two tabs already logged in" passes almost always and proves little. Test instead:

  1. A full login redirect in one tab while another tab is open and alive
  2. Renewal firing while a tab is frozen, then resuming and acting immediately
  3. Logout in one tab — the others should end cleanly, not hang
  4. Both tabs expired at once — exactly one authorize round trip should occur
  5. Deep link or returnUrl combined with a login redirect
  6. Third-party cookies blocked — iframe renewal must degrade to a redirect, not hang
  7. More than two tabs: count POST /token per renewal cycle. The answer should be one

Observability

Log leader/follower and lock wait time, the token iat at point of use versus the iat in storage, POST /token count per renewal cycle, and document.visibilityState at failure.

One metric is worth a dashboard: renewals per session per hour. It should stay flat regardless of tab count. If it scales with tabs, leader election isn't working.

Top comments (0)