Web Worker & Tabs
How @mrzr/api-client keeps access tokens inside a Web Worker away from XSS, when it falls back to the main thread, and how tabs share login and logout.
Tokens live in a Web Worker
By default every request runs inside a Web Worker. The token is stored there and attached there, so page JavaScript, including a script injected through XSS, can't read it.
Where is the token?
Log in, then search everywhere page JavaScript (or an injected script) could look for the token.
Not checked yet.
There is nothing to set up: the worker is bundled inline, with no extra file to host and no bundler plugin to add. It costs about 14 KB minified, a few milliseconds to start, and two postMessage hops per request. The worker and the main thread run the same compiled code, so behaviour can't drift between the two.
Responses are cleaned before they leave the worker: the session's tokens are removed from every response, and token fields are stripped from the login and refresh responses. So api.login() resolves on the page without the token in it.
When it falls back
The client silently uses the main thread instead, with identical behaviour, when:
- you pass
worker: false, or the code runs on a server (SSR, Node) - the browser blocks the worker. Your Content Security Policy must allow
worker-src 'self' blob: - the worker doesn't start within 10 seconds
extractTokensorbuildRefreshBodyis a function. Functions can't be sent into a worker; use their object forms
Check what you got with api.isWorker. A blocked worker is often reported only after the client exists, so check it after the first request.
Function options still work
Only extractTokens and buildRefreshBody turn the worker off. Other functions run on the page, and the worker calls them when it needs to:
| Option | Runs |
|---|---|
beforeFunc | On the page, before the body is sent to the worker |
afterFunc, beforeSelectOptions | On the page, after the result comes back |
getCsrfToken, xsrfCookieName | On the page: workers have no document.cookie, so the worker asks the page |
storage adapters | On the page, except "memory", which stays in the worker |
plugins | On the page, around each call |
onAuthStateChanged, onAuthFailure, onError, onLog | On the page |
Persistent storage ("local", "session", "cookie" or your own adapter) means the tokens pass through the page to be written somewhere page scripts can read anyway. Only "memory" keeps them entirely inside the worker.
What it can't protect
An injected script can still use your session. It can call api.get(...) just like your own code, so worker isolation stops token theft, not XSS itself. Also, storage: "local", "session" and "cookie" put the token where page JavaScript can read it. Keep the default "memory" to get the full benefit. See Security for the full picture.
Cleaning up
api.destroy() stops the worker, aborts its requests (they reject with Client destroyed) and closes the tab channel. A client you keep for the life of the page never needs it. Call it for short-lived clients: one per server request, one per test.
Tabs stay in sync
Clients with the same storageKey talk to each other over BroadcastChannel:
- Log out in one tab: every tab logs out.
- Log in in one tab: the others pick up the session (when storage is shared:
"local"or"cookie"). - Refresh: tabs that share a session take turns through the Web Locks API. When one tab has already refreshed, the next adopts its new tokens instead of spending the refresh token again. This matters when your server rotates refresh tokens and treats reuse as theft.
What reaches the other tabs depends on storage:
storage | logout() | A rejected refresh | New tokens after a refresh |
|---|---|---|---|
"memory", "session" | ✅ | ❌ each tab has its own session | ❌ |
"local", "cookie" | ✅ | ✅ | ✅ |
| Cookie mode | ✅ | ✅ | ✅ the browser shares the cookie |
The messages only carry a type, a tab id and an expiry time: never a token. The auth state's user isn't shared either, so a tab that learns about a login from another tab has no user until it fetches one, for example with api.restoreSession("/auth/me").
Reacting to a logout in another tab needs nothing special: onAuthStateChange and onAuthFailure fire the same way.
api.onAuthStateChange((state) => {
if (!state.isAuthenticated) {
queryClient.clear(); // don't leave the old user's data in this tab
router.push("/login");
}
});Two independent apps on one origin? Give each its own storageKey:
const adminApi = createClient({ storageKey: "admin" });
const shopApi = createClient({ storageKey: "shop" });Turn syncing off with multiTab: false. It's off on the server automatically, and where BroadcastChannel doesn't exist the client behaves as a single tab. To watch the traffic: new BroadcastChannel("apiclient.auth").onmessage = (e) => console.log(e.data).
Uploads & Downloads
Upload FormData, File, Blob and binary bodies with @mrzr/api-client, keep long uploads from failing on token expiry, and download files as a Blob.
WebSockets & Socket.io
Authenticate WebSocket and socket.io connections with @mrzr/api-client using a short-lived socket ticket, without exposing the access token to the page.