SPA Sessions Without Storage Leaks: Refresh Tokens, Service Workers, and Reality



Hook: The Service Worker That Became a Token Oracle
Your React app stores refresh tokens in IndexedDB so it can mint access tokens offline. A service worker handles background syncs and reads the token to prefetch data. Six months later, a Chrome extension used by customer support requests storage permission, gains access to IndexedDB, and exfiltrates refresh tokens. Another day, an XSS bug in a third-party script gives attackers the same access. Incident response traces logins from unrecognized devices to those tokens, and suddenly the team debates rewriting auth from scratch.
This article targets the reality of SPA session management: balancing UX with security. We will dissect token storage options, show how to implement rotating refresh tokens with HttpOnly cookies, add service worker boundaries, and test the flow end-to-end. No vendor pitches, just practical patterns you can drop into frameworks like Next.js and Vite.
The Problem Deep Dive
SPA sessions break down because:
- Local storage and IndexedDB are accessible to JavaScript. Any XSS or extension can read tokens.
- Refresh tokens stored in memory die on reload. Users hit login loops.
- **Cross-tab sync leaks tokens via
postMessageor BroadcastChannel. - **Service workers run in the same origin, so they inherit storage access.
- **CORS misconfigurations allow token refresh endpoints to be abused by other origins.
Common anti-pattern:
// auth.ts
export async function refresh() {
const refreshToken = localStorage.getItem("refresh_token");
const { access_token } = await fetchJSON("/oauth/token", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
});
sessionStorage.setItem("access_token", access_token);
}
In this setup, any script can read or overwrite refresh_token. Access tokens sit in session storage where XSS can steal them.
Technical Solutions
Quick Patch: HttpOnly Refresh Token Cookies
Store refresh tokens server-side, deliver them via HttpOnly SameSite cookies. Remove tokens from local storage entirely.
Server (Express):
res.cookie("refresh_token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/oauth/token",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
Client requests refresh by hitting /oauth/token with credentials: "include". Service workers cannot read the cookie.
Durable Flow: Rotating Refresh Tokens with Sliding Session
- Access token lives in memory only (
const accessTokenRef = useRef(null);). - Refresh tokens stored in HttpOnly cookie; rotation occurs on every refresh.
- The refresh endpoint sets new cookie and returns new access token.
- Use
Retry-Afterheader to instruct clients when to retry. - Add device binding metadata (user agent hash, IP) in backend to detect anomalies.
Client hook example (React):
async function refreshToken() {
const res = await fetch("/oauth/token", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "refresh_token" }),
});
if (!res.ok) throw new Error("refresh failed");
const data = await res.json();
accessTokenRef.current = data.access_token;
scheduleRefresh(data.expires_in);
}
Service Worker Isolation
- Avoid giving service workers direct access to backend resources needing tokens. Use background fetch with limited scope.
- If service worker must fetch with auth, have it use
fetchwithcredentials: "include"so cookies handle auth without storing tokens. - For push notifications, use VAPID tokens separate from user access tokens.
Cross-Tab Coordination
Use BroadcastChannel to share login state without tokens:
channel.postMessage({ type: "LOGIN", user: profile });
channel.onmessage = ({ data }) => {
if (data.type === "LOGOUT") logout();
};
Never broadcast raw tokens.
Backend Hardening
- Rate limit refresh endpoints per session.
- Invalidate refresh tokens after rotation (one-time use).
- Store refresh token hashes (like passwords) to detect reuse.
- Log user agent/IP per refresh.
Alprina Policies
Check codebase for localStorage.setItem("refresh_token" patterns. Flag service worker scripts accessing IndexedDB entries with token names. Ensure cookies set with httpOnly and sameSite attributes.
Testing & Verification
Write Cypress or Playwright tests covering:
- Login, refresh on navigation, logout.
- Tab B receiving logout message via BroadcastChannel.
- Service worker receiving fetch event and using cookie-based auth.
Add integration tests simulating stolen refresh tokens: call /oauth/token twice with same token, ensure second call fails and session invalidates.
Use ESLint rules to prevent localStorage usage for token keys:
"no-restricted-syntax": ["error", {
"selector": "CallExpression[callee.object.name='localStorage'][callee.property.name='setItem']",
"message": "Do not write tokens to localStorage"
}]
Common Questions & Edge Cases
What about legacy browsers without SameSite support? Provide compat fallback but warn users. Most modern browsers support SameSite now.
Can we use IndexedDB for offline mode? Only for encrypted payloads tied to device-specific keys (WebCrypto) and never for raw tokens.
Does this break native mobile webviews? Some webviews mishandle SameSite. Detect user agent and adjust SameSite to lax if necessary, but monitor risk.
How do we handle long-lived sessions? Extend expiration server-side but keep rotation. Add step-up auth for sensitive actions.
Is storing access tokens in memory enough? It mitigates persistent storage attacks but XSS can still read them. Combine with CSP, trusted types, and code audits.
Conclusion
SPA auth stays safe when tokens live on the server side. Move refresh tokens into HttpOnly cookies, keep access tokens ephemeral, lock down service workers, and test the whole flow. Users still enjoy seamless sessions, and you sleep without worrying about extensions rifling through IndexedDB.