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.html still gets its CSP enforced
  • your cached app.js still has to be allowed by script-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-src in 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-src covers dedicated workers, shared workers, and service workers in modern browsers.
  • Some older behavior fell back to script-src or child-src. If you support old browsers, test carefully.
  • manifest-src is 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:

  1. Is the app caching HTML with that nonce?
  2. Is the service worker itself covered by worker-src?
  3. Is there a manifest, and is manifest-src defined?
  4. Are offline API fallbacks or background sync endpoints included in connect-src?
  5. 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-src missing?
  • Is worker-src missing?
  • 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.