SPA Refresh Token Rotation Logs Out Multi-Tab Users
A customer-facing React SPA serves 18,000 users and calls a claims API. The SPA uses Authorization Code with PKCE against Okta's org authorization server and requests an access token. The API gateway requires aud equal to api://claims, but tokens carry api://default, so roughly one in five API calls returns 401. Access tokens last 60 minutes. The SPA has refresh token rotation enabled and stores both tokens in localStorage. When a user has three tabs open, two tabs often refresh within 300 ms of each other; one succeeds, the other gets invalid_grant for reusing the old refresh token, and that tab clears the session and redirects to login. Users report being logged out around the 60-minute mark. Security also flags that any XSS can read long-lived refresh tokens. You must fix the audience, stop the multi-tab logout, and propose a safer token-storage pattern without breaking the SPA deployment model.
1. Why are API calls returning 401, and what should be changed in the authorization server and token validation?
Read the worked answer
The SPA is using the org authorization server, whose access tokens are not designed to carry the custom API audience and scopes this gateway requires. The gateway is correctly rejecting a token whose aud is not api://claims.
Create or use a custom authorization server with an audience of api://claims and define the needed scopes, for example claims.read and claims.write. Register the SPA in that authorization server as a public client using Authorization Code with PKCE and no client secret. Request the API scopes plus openid and offline access if refresh is needed. The API gateway should validate the token's iss against that authorization server's issuer, aud, exp, nbf, alg, and kid using cached JWKS, and enforce scopes. It should not accept an ID token as an API access token or validate only the signature. Once the token is issued by the correct authorization server, the audience mismatch disappears; do not work around it by disabling audience checks.
2. How should the SPA handle refresh token rotation across multiple tabs so one tab does not invalidate another?
Read the worked answer
Refresh token rotation means the old refresh token is single-use; two tabs presenting it concurrently is a race, and the loser receives invalid_grant. The fix is a single-flight refresh coordinated across tabs.
Use a Service Worker or a BroadcastChannel lock so only one tab calls the token endpoint. That tab refreshes, atomically stores the new token set, and broadcasts the result; other tabs wait for the broadcast instead of calling refresh. If the leader fails, followers should re-read storage once before deciding to log out. Never retry the old refresh token after invalid_grant; clear all tabs and require reauthentication. If your authorization server supports a short refresh-token grace period, it can reduce races, but it should not replace client-side coordination. For stronger isolation across browser contexts, move refresh to a backend-for-frontend that holds the refresh token and exposes a same-site session cookie.
3. What token-storage pattern would you recommend, and what trade-offs remain?
Read the worked answer
localStorage is readable by any JavaScript in the origin, so an XSS bug turns into long-lived refresh token theft. Prefer a backend-for-frontend: the SPA keeps the access token in memory, while the refresh token lives in an HttpOnly, Secure, SameSite cookie and the backend performs refreshes. This removes refresh tokens from JavaScript and centralizes rotation.
If a backend is not feasible, keep the access token in memory, use a short refresh-token lifetime with rotation, enforce a strict CSP, avoid third-party scripts on the origin, and consider sessionStorage over localStorage only as a limited reduction in exposure, not a fix. Access tokens should be short-lived and audience-restricted; ID tokens should not be sent to the API. Document the residual XSS risk and the reauthentication flow rather than claiming storage alone makes the SPA immune.