Two tabs, one refresh token
A logout bug that most test suites never see, because they open only 1 tab.
If your sign-in provider rotates refresh tokens, a user with 2 tabs open can be logged out for no visible reason. Both tabs send the same refresh token, and the provider treats the second as stolen. The fix is to let only 1 tab refresh at a time, using the browser’s Web Locks API.
An access token is a short-lived credential sent with each API call. A refresh token is a longer-lived one that gets a new access token. With rotation, each refresh also returns a new refresh token and retires the old one. If a retired one comes back, the provider assumes theft and revokes them all.
When the access token expires, both open tabs get a 401 (Unauthorized) response and send the same refresh token. The second is a reuse, so the provider logs the user out in every tab. RFC 9700, the OAuth security guidance, admits the server “cannot determine which party submitted the invalid refresh token.”
Let 1 tab refresh at a time
Some providers, such as Auth0 with its “Rotation Overlap Period”, accept the old token for a few seconds. I prefer the client fix, because it keeps reuse detection strict. Make the refresh single-flight, so only 1 refresh runs and other callers wait for its result. The Web Locks API gives you a named lock that every tab on the same origin shares. An origin is the scheme, host and port together, such as https://example.com.
// Runs only while holding the lock.
async function refreshOnce() {
// Another tab may have refreshed
// while we waited, so read again.
const t = readTokens();
const now = Date.now() / 1000;
const left = t.exp - now;
if (left > 30) return t.access;
// Only 1 tab at a time gets here.
const fresh = await refresh(t);
writeTokens(fresh);
return fresh.access;
}
function accessToken() {
return navigator.locks
.request("auth", refreshOnce);
}
readTokens and writeTokens use storage all tabs share, such as localStorage. The second tab waits, re-reads storage, finds the fresh token and never calls the provider. Without that re-read, the 2 refreshes run one after the other, and the provider still sees 2.
The same race happens inside 1 tab, when 5 API calls fail with a 401 together. So any refresh-on-401 handler that many callers can hit at once needs this guard.