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
storageevent listener that turns another tab's token writes intoadded/removedevents, which refreshauthState - 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
expireEarlySecondsis 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_verifiersits 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
-
One
OktaAuthinstance per document. Construct once, export it. - 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.
-
Serialize the login redirect too. Followers wait for tokens to appear via the
storageevent. - Never clear whole-origin storage. Remove only your own namespaced keys, and never while a PKCE transaction exists.
-
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. -
Publish everything you write. Route writes through
tokenManager.setTokens()so events fire; returning a token read straight from storage leavesauthStatestale. - Set the rotation grace period above your worst-case lock handoff.
-
Treat
invalid_grantas "someone else rotated it" first — re-read storage once before declaring failure, and re-authenticate rather than deleting the shared refresh token. - Renew only while the document is visible. Frozen or discarded tabs stall requests and then fire overdue timers on resume.
- 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:
- A full login redirect in one tab while another tab is open and alive
- Renewal firing while a tab is frozen, then resuming and acting immediately
- Logout in one tab — the others should end cleanly, not hang
- Both tabs expired at once — exactly one authorize round trip should occur
- Deep link or returnUrl combined with a login redirect
- Third-party cookies blocked — iframe renewal must degrade to a redirect, not hang
- More than two tabs: count
POST /tokenper 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)