Progressive web apps make CSP slightly weirder than normal websites.
A regular site mostly loads fresh resources over the network. A PWA keeps working when the network is gone, which means scripts, HTML, and API responses may come from the service worker cache instead of the server. That changes how people debug CSP failures, and it changes how you should think about policy rollouts.
The short version: cached resources still need to obey CSP. Offline support does not bypass browser enforcement. But service workers can absolutely make CSP behavior confusing if you cache old HTML, stale JS, or third-party responses that no longer match your current policy.
What CSP still does in a PWA
CSP applies when the browser processes the document and fetches subresources for it.
That means:
- your cached
index.htmlstill gets its CSP enforced - your cached
app.jsstill has to be allowed byscript-src - your cached CSS still has to be allowed by
style-src - network requests made by your app or service worker still have to fit
connect-srcin the page context - service worker registration and execution have their own constraints
What CSP does not do:
- it does not stop your service worker from storing bad content if your app logic caches it
- it does not magically update old cached HTML with your new header
- it does not fix stale nonces embedded in previously cached pages
That last one bites people all the time.
The biggest PWA CSP footgun: cached HTML with old nonces
If your app serves HTML with nonce-based inline scripts and you cache that HTML for offline use, you can break your own app.
Example cached HTML:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>PWA</title>
</head>
<body>
<div id="app"></div>
<script nonce="abc123">
window.__BOOTSTRAP__ = {"theme":"dark"};
</script>
<script nonce="abc123" src="/app.js"></script>
</body>
</html>
Example response header when originally served:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; object-src 'none'; base-uri 'self'
If that HTML gets cached and replayed later, the browser still sees nonce abc123 in the markup. If your current server would have generated a different nonce, that does not matter for the cached document. The browser evaluates the cached document as-is.
That sounds fine until your service worker mixes old HTML with newer assets, or you assume a nonce rotates per request but then deliberately serve the same cached page over and over. At that point, nonce freshness is gone.
My rule for offline-first apps: avoid inline scripts in cacheable HTML. Use external scripts wherever possible.
Better:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self'
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="app"></div>
<script src="/app.js"></script>
</body>
</html>
That is much easier to cache safely.
Service worker basics that affect CSP
A PWA usually has a service worker like this:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
For that to work cleanly, your policy should explicitly allow the script and worker context.
Copy-paste baseline:
Content-Security-Policy:
default-src 'self';
script-src 'self';
worker-src 'self';
connect-src 'self';
img-src 'self' data:;
style-src 'self';
manifest-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
A few notes:
worker-srccovers dedicated workers, shared workers, and service workers in modern browsers.- Some older behavior fell back to
script-srcorchild-src. If you support old browsers, test carefully. manifest-srcis easy to forget in PWAs.
Example HTML:
<link rel="manifest" href="/manifest.webmanifest">
<script src="/bootstrap.js"></script>
Cached resources do not get a free pass
Say your service worker caches an analytics script from a third party:
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('runtime-v1').then(async cache => {
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
})
);
});
If your page later tries to execute that cached third-party script, it still must be allowed by script-src.
So this policy blocks it:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
Even if the file comes from your service worker cache, the effective source is still evaluated against CSP rules. Offline does not equal trusted.
Real-world policy shape for a PWA with third parties
The header from Headertest is a realistic example of a production CSP with analytics and consent tooling:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MzVhYmM3ZDEtYTdkYy00ODUyLThhM2YtMjY4MTkxYjE0MTRh' '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'
For a PWA, I would review this with a few extra questions:
- Is the app caching HTML with that nonce?
- Is the service worker itself covered by
worker-src? - Is there a manifest, and is
manifest-srcdefined? - Are offline API fallbacks or background sync endpoints included in
connect-src? - Are stale consent or analytics scripts being cached longer than intended?
A tightened PWA-aware version might look like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' '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;
manifest-src 'self';
worker-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
If you want more policy patterns, the ready-made examples at https://csp-examples.com are useful for comparing strict and compatibility-focused setups.
Safe caching patterns for CSP-friendly PWAs
1. Cache static JS and CSS, not dynamic HTML
Best pattern:
- network-first or revalidate for HTML
- cache-first for versioned JS/CSS/images/fonts
- avoid caching nonce-bearing documents long-term
Example service worker strategy:
const STATIC_CACHE = 'static-v3';
const STATIC_ASSETS = [
'/app.js',
'/app.css',
'/offline.html',
'/icons/icon-192.png',
'/manifest.webmanifest'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => cache.addAll(STATIC_ASSETS))
);
});
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
if (req.mode === 'navigate') {
event.respondWith(
fetch(req).catch(() => caches.match('/offline.html'))
);
return;
}
if (url.origin === location.origin) {
event.respondWith(
caches.match(req).then(cached => cached || fetch(req))
);
}
});
Then make /offline.html simple and external-script only:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Offline</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<h1>You’re offline</h1>
<script src="/app.js"></script>
</body>
</html>
2. Version your assets
If /app.js changes behavior to match a new CSP, give it a new URL:
<script src="/assets/app.4f3c1d2a.js"></script>
That makes cache invalidation less painful and avoids weird “old file under new policy” bugs.
3. Keep offline fallback pages boring
Your offline page should not depend on:
- inline scripts
- third-party analytics
- consent managers
- cross-origin fonts
- fragile bootstrapping data blobs
A boring fallback page is a reliable fallback page.
Common CSP directives PWAs tend to need
Here is a practical baseline:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
manifest-src 'self';
worker-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
If you use inline styles from a framework during boot, you may temporarily need:
style-src 'self' 'unsafe-inline'
I try hard to remove that later.
If your app calls an API on another origin:
connect-src 'self' https://api.example.com
If your service worker uses WebSocket-backed realtime sync:
connect-src 'self' https://api.example.com wss://realtime.example.com
Debugging checklist for “works online, breaks offline”
When a PWA fails only offline, I usually check these first:
- Is the cached HTML old?
- Does cached HTML contain stale nonces or hashes?
- Does the offline page try to load blocked third-party scripts?
- Is
manifest-srcmissing? - Is
worker-srcmissing? - Did a cached script move to a new origin not listed in CSP?
- Is the app making offline boot requests to endpoints not covered by
connect-src?
A nasty variant is when DevTools shows a file coming from service worker and people assume CSP does not apply. It still does.
Report-Only first, especially for service workers
For existing PWAs, start with Report-Only before enforcing:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
worker-src 'self';
manifest-src 'self';
object-src 'none';
base-uri 'self';
Then test:
- fresh install
- upgrade from older cached version
- airplane mode
- offline fallback navigation
- service worker update flow
- “hard refresh” and normal refresh
PWAs have more states than normal sites. Your CSP needs to survive all of them, not just the happy path.
For the formal directive behavior, the official documentation is still the best reference: https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy
My default opinionated setup for most PWAs is simple: external scripts only, cache static assets aggressively, keep HTML fresh, define worker-src and manifest-src, and treat cached documents with nonces as suspicious until proven safe. That avoids most of the ugly CSP/offline bugs I see in production.