A service worker is a privileged intermediary that lives permanently on your origin and passes the app's network requests through itself. Privileged, literally: it has no UI, it survives tab closes, there is one per origin, and it decides what a fetch resolves to - from the network, from the cache, or synthesized. In authority this is closer to a server than to a page script.
Because the worker holds that authority, its delivery to the client is an attack surface, not a build detail. The script must arrive only from your own origin and over HTTPS; pulling it from a foreign domain or wiring in external code through importScripts hands control of the network to a third party. Protect the deploy: a strict CSP, Subresource Integrity where applicable, dependency review, immutable assets. A single compromised dependency in the worker bundle gets not one page but all of the origin's traffic for the whole lifetime of the registration.
The second front is the cache as a leak surface. The reaction to offline-first is natural: we need to work without a network, so cache everything that passes through fetch. This is where the security model breaks. Cache Storage does not tell public from private - it will just as happily hold the app shell and a private API response full of someone else's data. Responses to POST, auth callbacks, tokens, banking and medical data are not cached without an explicit threat model: put a secret on disk and you turn a temporary value into a long-lived artifact.
It matters where this data sits. Cache Storage and IndexedDB are on-disk stores, shared across the origin and outliving the session. Any code on your origin reads them without authorization; an XSS that on a normal site would steal one session gains, in a PWA, the entire offline archive. So private responses, if cached at all, go into user-scoped namespaces - a name like api-userId rather than a shared api.
Hence the logout discipline. Signing out is not only clearing a token in memory; it is deleting the user-scoped Cache Storage and IndexedDB and revoking the push subscription per your product policy. Unregistering the worker is usually unnecessary: we remove the private data while the worker keeps serving the public app shell.
A separate trap is the outbox, the queue of deferred operations. The temptation to drop a bearer token into it is strong - do not. A token on disk is a leaked secret, and by send time it will have expired anyway. The outbox holds an intent - what to do and with which data, not a permission. At send time the operation takes the current secure auth model; an expired session does not fail the operation silently but moves it to auth-required until the next sign-in.
Last comes cache integrity. A cached response is trusted as far as its source was trusted at write time. Verify that no foreign tenant's response or content swapped by an intermediary made it into the cache, and separate users and tenants at the key level. Cache poisoning is worse in offline-first: a poisoned response stays on disk until an explicit invalidation.
The running example shows it. The notes app caches GET /api/notes to show the list instantly. If the cache key is only the URL, then on a shared tablet a second employee, after signing in, sees the first one's notes - same URL, same response. The fix is keying the private API cache to the user, and the first one's logout wipes that cache. Security here is not a layer on top of offline but part of the data model: until you have answered whose data this is and when to erase it, offline merely widens the leak window.
async function purgePrivateOfflineData(userId) {
await Promise.all([
caches.delete(`api-${userId}`),
clearUserStores(userId)
]);
// unregister on logout is usually unnecessary: we delete private data,
// while the worker keeps serving the public app shell.
}