-
Notifications
You must be signed in to change notification settings - Fork 0
Security Model
An honest account of what this client protects against, and what it doesn't.
| Threat | Protection | Effectiveness |
|---|---|---|
| Token theft via XSS | Worker isolation + memory storage | Strong — no readable variable, no storage key |
| Token use via XSS | None | None — see below |
| Token sent to another origin | Token and CSRF header only go to the baseUrl origin, the page's own origin and authOrigins. Each URL is resolved once, with the platform's URL parser, and that exact URL is both checked and fetched |
Strong — an injected or third-party URL gets no credentials, whatever its spelling (//host, \\host, /\host) |
| Token read by page code |
getAccessToken() refused unless exposeTokens: true; sockets get a server-issued ticket via getSocketToken(); every response leaving the worker has the session's tokens redacted, and responses from the login/refresh endpoints lose their token fields |
Strong unless you opt in — see the caveat below |
| Plugins | Plugins run on the page and never see the token; a failing plugin fails one call. They can add trusted origins, so install only plugins you trust | By design — see Plugins |
| Path injection via URL values |
addTemplateToUrl / addToUrl values encoded as one segment |
Strong |
| CSRF | Double-submit header mirroring | Strong, if your server enforces it |
| Token leakage in logs | Tokens never enter LogEntry, AuthState or tab messages |
Strong |
| Cross-tab desync | BroadcastChannel logout propagation | Strong |
| Refresh stampede / token reuse | Promise coalescing + cross-tab Web Lock | Strong where Web Locks exists |
| MITM | Not addressed — use HTTPS | n/a |
| Malicious dependency | Zero runtime dependencies | Strong |
The worker redacts the session's own tokens from every response, and strips token fields from the login and refresh endpoints. It cannot know about other endpoints of yours that hand out a new credential to an authenticated caller — an OAuth token exchange, an API-key endpoint, a socket-ticket endpoint. Injected script can call those through the client like any other request and read what they return. Keep such credentials short-lived and narrowly scoped, and don't return long-lived bearer tokens from them.
Worker isolation prevents token theft. It cannot stop an attacker who already has XSS from using your client.
An injected script can call api.post("/transfer", { to: "attacker" }) and the worker will attach the token. Isolation stops one specific thing: the token leaving the browser. That holds because the token is only attached for trusted origins — api.get("https://attacker.example") goes out without it.
Why that still matters:
| Without isolation | With isolation |
|---|---|
| Token exfiltrated to the attacker's server | Token stays in the worker |
| Attack continues after the tab closes | Attack dies with the page |
| Token replayed from anywhere | Requests must originate from your page |
| Refresh token stolen → indefinite access | Refresh token never leaves |
You go from "the attacker owns this account indefinitely" to "the attacker can act while the user has the tab open". Meaningful, but not a substitute for preventing XSS.
Prevent XSS first. Isolation is defence in depth, not a replacement for a strict CSP, output encoding and dependency hygiene.
Most secure
│
├─ authMode: "cookie" + httpOnly cookies ← the client never sees a token
├─ header + storage:"memory" + worker:true ← token unreachable from page JS
├─ header + storage:"memory" + worker:false ← in a main-thread closure
├─ header + storage:"session" ← readable by any page script
├─ header + storage:"local" ← readable, and persistent
└─ header + storage:"cookie" (non-httpOnly) ← readable, and sent everywhere
│
Least secure
Reload behaviour is the trade-off:
| Setup | Survives reload? | Recovery |
|---|---|---|
| Cookie mode | ✅ | The browser holds it |
"memory" |
❌ | Call api.refresh() on boot against an httpOnly refresh cookie |
"session" |
✅ same tab | – |
"local" / "cookie"
|
✅ | – |
The recommended header-mode setup — no re-login, no readable token:
// Server sets a long-lived httpOnly refresh cookie at login.
const api = createClient({
baseUrl,
storage: "memory",
worker: true,
buildRefreshBody: () => ({}), // the cookie carries the refresh token
credentials: "include",
});
await api.refresh(); // mints a fresh in-memory access token on bootNote:
buildRefreshBodydisables worker mode. If you need both, have the server accept an empty JSON body by default so no custom builder is required.
createClient({
baseUrl: "https://api.example.com",
authMode: "cookie",
xsrfCookieName: "csrftoken",
xsrfHeaderName: "X-CSRF-Token",
});Server:
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Strict; Path=/
Set-Cookie: csrftoken=…; Secure; SameSite=Strict; Path=/
createClient({
baseUrl: "https://api.vendor.com",
authMode: "header",
storage: "memory",
worker: true,
refreshSkewMs: 30_000,
onAuthFailure: () => router.push("/login"),
});createClient({
baseUrl,
authMode: "header",
storage: "local",
worker: true, // still worth it: the *live* token stays in the worker
});Pair with a strict CSP. Note that "local" means the persisted copy is readable regardless of worker mode.
A CSP that supports this client and defends against XSS:
Content-Security-Policy:
default-src 'self';
script-src 'self';
worker-src 'self' blob:;
connect-src 'self' https://api.example.com;
style-src 'self';
img-src 'self' data: https:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
Key directives:
-
worker-src blob:— required for worker isolation. Without it, the client silently falls back to the main thread. -
connect-src— restrict wherefetchmay go. This is what actually blocks exfiltration to an attacker's domain, and it's the directive that makes worker isolation hard to bypass. - No
'unsafe-inline', no'unsafe-eval'— these are what make XSS trivially exploitable.
- ❌ Put a token in a URL or query string
- ❌ Put a token in a log entry, an
AuthState, or a tab message - ❌ Verify a JWT signature (that's the server's job; client-side verification is theatre)
- ❌ Send
Authorizationcross-origin without yourbaseUrlpointing there - ❌ Send the CSRF header on safe methods (
GET) - ❌ Retry a 401 more than once
- ❌ Store anything under a key you didn't configure (
storageKey)
const api = createClient(options);
console.table({
isWorker: api.isWorker, // false means no isolation — is that intended?
authMode: options.authMode ?? "header",
storage: options.storage ?? "memory",
csrf: Boolean(options.xsrfCookieName || options.getCsrfToken),
multiTab: options.multiTab !== false,
});Checklist:
-
api.isWorker === truein production browsers (orworker: falseis a deliberate choice) -
storageis"memory", or you've accepted the XSS exposure - Cookie mode has CSRF configured and the server enforces it
- CSP includes
worker-src blob:and a tightconnect-src -
baseUrlis HTTPS in production -
onAuthFailureclears app caches (queryClient.clear()), not just the route - Tokens are short-lived (15 min or less) with rotation on refresh
- No token appears in any analytics or error-reporting payload
Open a security advisory rather than a public issue.
Next: Client Options
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides