Service worker - привилегированный посредник, который постоянно живёт на вашем origin и пропускает через себя сетевые запросы. Привилегированный - буквально: у него нет UI, он переживает закрытие вкладок, он один на весь origin и он решает, каким будет ответ на fetch - из сети, из кеша или синтезированным. По объёму власти это ближе к серверу, чем к скрипту страницы.
Раз воркер обладает этой властью, его доставка на клиент - поверхность атаки, а не деталь сборки. Скрипт должен приезжать только с вашего origin и по HTTPS; тянуть его с чужого домена или подключать через importScripts внешний код значит отдать управление сетью третьей стороне. Защищайте выкладку: строгий CSP, Subresource Integrity где применимо, ревью зависимостей, неизменяемые ассеты. Одна скомпрометированная зависимость в бандле воркера получает не одну страницу, а весь трафик origin на всё время жизни регистрации.
Второй фронт - кеш как поверхность утечки. Реакция на offline-first естественна: нужна работа без сети - закешируем всё, что проходит через fetch. Здесь модель безопасности и ломается. Cache Storage не различает публичное и приватное - в него одинаково ляжет и app shell, и ответ приватного API с чужими данными. Ответы на POST, auth-callback, токены, банковские и медицинские данные не кешируют без явной threat model: положив секрет на диск, вы превращаете временное значение в долгоживущий артефакт.
Важно, где эти данные лежат. Cache Storage и IndexedDB - хранилища на диске, общие для всего origin и переживающие сессию. Любой код на вашем origin читает их без авторизации; XSS, который на обычном сайте украл бы одну сессию, в PWA получает весь офлайн-архив. Поэтому приватные ответы, если их вообще кешируют, кладут в привязанные к пользователю namespace - имя вроде api-userId, а не общий api.
Отсюда дисциплина logout. Выход - это не только сброс токена в памяти; это удаление user-scoped Cache Storage и IndexedDB и отзыв push-подписки по продуктовой политике. Снимать регистрацию воркера обычно не нужно: удаляем приватные данные, а публичный app shell воркер пусть обслуживает.
Отдельная ловушка - outbox, очередь отложенных операций. Соблазн положить в неё bearer-токен велик - не делайте так. Токен на диске - утёкший секрет, а к моменту отправки он всё равно протухнет. В outbox лежит намерение - что сделать и с какими данными, а не разрешение. При отправке операция берёт текущую безопасную auth-модель; истёкшая сессия не проваливает операцию молча, а переводит её в auth-required до нового входа.
Последнее - целостность кеша. Кешированному ответу доверяют настолько, насколько доверяли источнику при записи. Проверяйте, что в кеш не попал ответ чужого арендатора или подменённый промежуточным узлом контент, и различайте пользователей и tenant на уровне ключей. Cache poisoning в offline-first опаснее обычного: отравленный ответ остаётся на диске до явной инвалидации.
На живом примере это видно. Приложение заметок кеширует GET /api/notes, чтобы показать список мгновенно. Если ключом кеша взять только URL, на общем планшете второй сотрудник после входа увидит заметки первого - тот же URL, тот же ответ. Лечится привязкой кеша приватного API к пользователю, а logout первого этот кеш стирает. Безопасность здесь не слой поверх офлайна, а часть модели данных: пока не ответили, чьи это данные и когда их стирать, офлайн лишь расширяет окно утечки.
async function purgePrivateOfflineData(userId) {
await Promise.all([
caches.delete(`api-${userId}`),
clearUserStores(userId)
]);
// unregister при logout обычно не нужен: удаляем private data,
// а worker продолжает обслуживать public app shell.
}