Service Workers and CSP have an awkward relationship.
A lot of developers assume CSP either fully controls what a Service Worker can do, or barely touches it at all. Neither is true. The browser applies CSP in a few very specific places, and if you get those wrong, your worker won’t register, won’t update, or will quietly fail in ways that are annoying to debug.
I’ve seen teams lock down page CSP nicely, then wonder why their offline cache logic broke after moving sw.js behind a CDN path, or why a worker-installed fetch started behaving differently than page fetches. The root problem is usually the same: they’re treating the page, the worker script, and worker-initiated requests as one security context. They aren’t.
The mental model
There are three separate pieces to think about:
- The page that registers the Service Worker
- The Service Worker script itself
- The network requests made by the Service Worker
Each one has different CSP behavior.
At a high level:
- The page’s CSP controls whether the page is allowed to register the worker script.
- The worker script response’s CSP controls what the worker itself is allowed to load or execute.
- Fetches initiated by the worker are governed by the worker’s own CSP, not the page’s CSP.
That distinction matters a lot.
First: registering a Service Worker is gated by worker-src
If your page does this:
<script nonce="abc123">
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
</script>
the browser checks the page’s CSP to see whether that worker URL is allowed.
The relevant directive is:
worker-srcfor workers, including Service Workers- If
worker-srcis missing, browsers fall back tochild-src - If
child-srcis also missing, they fall back toscript-src - Then eventually
default-src
So if you want to be explicit, do this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
worker-src 'self';
That says:
- scripts can run from self, plus nonce-approved inline bootstrap code
- workers can only be loaded from same-origin URLs
If worker-src doesn’t allow /sw.js, registration fails.
Example: blocked registration
Content-Security-Policy:
default-src 'self';
script-src 'self';
worker-src https://static.examplecdn.com;
Then this will fail:
navigator.serviceWorker.register("/sw.js");
because /sw.js is on self, not https://static.examplecdn.com.
The browser console usually gives a CSP violation message, but people often miss it because they’re focused on Service Worker lifecycle logs.
Your page CSP does not become the worker CSP
This is the big one.
If your page sends a strict CSP header, that does not automatically sandbox the Service Worker’s own execution environment. The worker script is fetched as its own resource, and the CSP attached to the worker script response becomes the worker’s policy.
So if /index.html sends:
Content-Security-Policy:
default-src 'self';
connect-src 'self' https://api.example.com;
worker-src 'self';
that controls the page.
But if /sw.js sends no CSP at all, then the worker runs without its own explicit CSP restrictions. That surprises a lot of people.
If you care about what your worker can fetch, import, or execute, serve a CSP header on sw.js too.
Service Worker fetches use the worker’s CSP
Suppose your worker does this:
self.addEventListener("activate", event => {
event.waitUntil((async () => {
await fetch("https://api.example.com/bootstrap");
})());
});
Whether that fetch is allowed depends on the CSP on the Service Worker script response, specifically connect-src.
A practical worker CSP might look like this:
Content-Security-Policy:
default-src 'self';
script-src 'self';
connect-src 'self' https://api.example.com;
img-src 'self';
object-src 'none';
base-uri 'none';
If connect-src doesn’t include https://api.example.com, the worker’s fetch can be blocked even if the page itself allows it.
That’s where people get tripped up: the page can fetch one thing, while the worker cannot, or the other way around.
importScripts() and classic Service Workers
If you still use a classic Service Worker:
// /sw.js
importScripts("/sw-lib.js");
the worker’s CSP controls whether that import is allowed. In practice, script-src is the directive that matters here.
Example:
Content-Security-Policy:
default-src 'self';
script-src 'self';
This allows:
importScripts("/sw-lib.js");
But blocks:
importScripts("https://cdn.example.com/sw-lib.js");
unless you add that origin to script-src.
Classic workers are still common, but module Service Workers are cleaner if your browser support target allows them.
Module Service Workers behave more like modern JS
Registering a module worker:
navigator.serviceWorker.register("/sw.js", { type: "module" });
Now your worker can use imports:
// /sw.js
import { precache } from "./precache.js";
self.addEventListener("install", event => {
event.waitUntil(precache());
});
Again, the worker script response’s CSP applies. script-src governs what module imports are allowed.
If you lock script-src to 'self', same-origin module imports work fine:
Content-Security-Policy:
default-src 'self';
script-src 'self';
If your module worker tries to import cross-origin code, expect CSP to block it unless you explicitly allow that source.
My advice: keep Service Worker code same-origin. Don’t make your caching layer depend on third-party script hosts. That’s brittle even before CSP enters the picture.
strict-dynamic on the page does not magically help your worker
Here’s the real CSP header from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-NjFlYWRkYWYtZTk1My00NGJiLTg5NGMtMDc4NjMzZmY5NmJj' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
A few observations from a Service Worker angle:
- There is no explicit
worker-src - So worker registration falls back to
script-src script-srcincludes'self', so a same-origin/sw.jsis allowed- The nonce and
'strict-dynamic'are relevant to page script execution, not to whether a worker can do arbitrary network fetches later - The page’s
connect-srcdoes not automatically become the worker’sconnect-src
If headertest.com serves /sw.js without its own CSP, the worker won’t inherit that big policy. That’s the part many developers miss.
A safe pattern for page + worker CSP
I like to treat them as separate policies.
Page response
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
worker-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
/sw.js response
Content-Security-Policy:
default-src 'self';
script-src 'self';
connect-src 'self' https://api.example.com;
img-src 'self';
object-src 'none';
base-uri 'none';
That gives you:
- explicit control over worker registration
- explicit control over worker-initiated fetches
- less accidental drift between page and worker behavior
If you need starter policies, https://csp-examples.com is useful for ready-to-use examples.
Example app: page registers worker, worker caches API data
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CSP + Service Worker Demo</title>
</head>
<body>
<h1>Demo</h1>
<script nonce="build-nonce-123">
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { scope: "/" })
.then(reg => console.log("SW registered", reg.scope))
.catch(err => console.error("SW registration failed", err));
}
</script>
</body>
</html>
Page CSP
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-build-nonce-123';
worker-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
sw.js
self.addEventListener("install", event => {
event.waitUntil((async () => {
const cache = await caches.open("app-v1");
await cache.addAll([
"/",
"/styles.css",
"/app.js"
]);
})());
});
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", event => {
const url = new URL(event.request.url);
if (url.pathname === "/api/data") {
event.respondWith((async () => {
const cache = await caches.open("api-v1");
try {
const fresh = await fetch(event.request);
cache.put(event.request, fresh.clone());
return fresh;
} catch {
const cached = await cache.match(event.request);
if (cached) return cached;
return new Response("offline", { status: 503 });
}
})());
}
});
Worker CSP
Content-Security-Policy:
default-src 'self';
script-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
If later you change the worker to fetch https://api.example.com/api/data, you must update the worker CSP:
connect-src 'self' https://api.example.com;
Updating only the page CSP won’t fix it.
Common mistakes
1. Forgetting worker-src
If you rely on script-src fallback, things may work today and break later when someone tightens script policy or moves assets around. Be explicit.
2. Setting CSP on HTML but not on sw.js
This gives a false sense of coverage. Your page can be tightly locked down while the worker remains much more permissive.
3. Assuming page nonces affect Service Worker internals
They don’t. Nonces are for script elements in documents. They are not a general “trust token” for worker execution.
4. Debugging only registration, not worker fetches
A worker can register successfully and still fail every cross-origin bootstrap fetch because its own connect-src is too strict.
5. Serving sw.js from the wrong path or origin
Service Workers are already strict about origin and scope. CSP adds another layer. Keep the file same-origin and simple.
What I recommend in production
My default stance:
- Set
worker-src 'self'on pages - Serve a dedicated CSP on
sw.js - Keep worker code and imports same-origin
- Keep worker
connect-srcminimal - Don’t assume page and worker policies are shared
- Test worker install, activate, update, and offline paths with CSP enabled from day one
For reference on browser behavior and directive definitions, check the official CSP and Service Worker documentation on MDN and the CSP spec documentation from the W3C.
If you remember one thing, make it this: your Service Worker is its own CSP world. The page controls whether it can be loaded. After that, the worker lives or dies by the policy sent with sw.js.