@mrzr/api-client

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
  • extractTokens or buildRefreshBody is 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:

OptionRuns
beforeFuncOn the page, before the body is sent to the worker
afterFunc, beforeSelectOptionsOn the page, after the result comes back
getCsrfToken, xsrfCookieNameOn the page: workers have no document.cookie, so the worker asks the page
storage adaptersOn the page, except "memory", which stays in the worker
pluginsOn the page, around each call
onAuthStateChanged, onAuthFailure, onError, onLogOn 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:

storagelogout()A rejected refreshNew 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).

On this page