Skip to content

Content Security Policy for Progressive Web Apps

Content Security Policy (CSP) is the browser's allowlist for what a document or worker may load and execute, and a strict policy is the most effective second line of defense against cross-site scripting. PWAs complicate CSP in ways classic sites do not: the service worker is governed by its own policy taken from its script response, the worker decides which headers (including CSP) every document it serves ends up with, cached app shells cannot carry fresh nonces, and cross-origin isolation headers change which cached responses remain usable. This page explains how each policy is determined, which directives matter for PWAs, how to run a strict CSP on an offline-first app, how to add Trusted Types and reporting, and how COOP, COEP and CORP interact with service worker caching.

Key takeaways

  • A PWA has at least two policies: the document's (from the navigation response) and the service worker's (from the sw.js response). The page's worker-src decides whether a worker may be registered; the worker's own script-src and connect-src decide what it may import and fetch.
  • When the worker answers a navigation, the response it returns determines the document's CSP. A new Response(html) without headers produces a page with no policy at all. Wrap every navigation response in code that sets your security headers.
  • Per-response nonces do not survive caching. For app shells served from Cache Storage, use a hash-based strict policy ('sha256-…' 'strict-dynamic'), and keep nonces for network-first, server-rendered HTML.
  • Set an explicit worker-src. With 'strict-dynamic' in script-src and no worker-src, any script-created worker is allowed. A path-level value such as https://app.example.com/sw.js blocks rogue worker registrations.
  • Trusted Types (Chrome 83, Firefox 148, Safari 26 per MDN) also cover navigator.serviceWorker.register() and importScripts(), so worker URLs must pass through a policy.
  • Report with report-to plus Reporting-Endpoints, keep report-uri for older engines, and send reporting headers on sw.js too.
  • Under Cross-Origin-Embedder-Policy, responses from the service worker are CORP-checked against the page's policy, and Cache Storage checks opaque entries against the worker's own policy. Cache cross-origin assets in CORS mode or make sure they carry Cross-Origin-Resource-Policy.

How CSP applies to a PWA

CSP policies attach to global objects: each Document, dedicated worker, shared worker and service worker has a policy container holding its own CSP list. For a PWA, three sources matter:

Context Where its CSP comes from What it governs
Document (page) Content-Security-Policy headers on the navigation response, plus any <meta http-equiv="Content-Security-Policy"> elements in the HTML Everything the page loads and executes, including whether it may register a service worker (worker-src) and fetch the manifest (manifest-src).
Service worker Content-Security-Policy headers on the worker script response (/sw.js) importScripts() (script-src), every fetch() and cache.add() the worker makes (connect-src), and Trusted Types inside the worker.
Dedicated worker The worker script response for http(s) URLs; inherited from the creating document for blob: and data: URLs The worker's own loads.

The service worker specification is explicit about the worker's policy: "If serviceWorker's script resource was delivered with a Content-Security-Policy HTTP header containing the value policy, the user agent must enforce policy for serviceWorker," and a Content-Security-Policy-Report-Only header is monitored the same way. The page's policy does not apply inside the worker, and the worker's policy does not apply to the page.

sequenceDiagram
    participant Page
    participant SW as Service worker
    participant Net as Network
    Page->>Page: Page CSP pre-request check (img-src, script-src, ...)
    Page->>SW: fetch event (only if allowed)
    SW->>SW: Worker CSP check (connect-src)
    SW->>Net: fetch(event.request)
    Net-->>SW: Response
    SW-->>Page: respondWith(response)
    Page->>Page: Page CSP post-request check on the response

Two checks happen on every intercepted subresource request. The page's policy runs its pre-request check before the request reaches the worker, so a request the page's policy blocks never produces a fetch event. Then the worker's own fetch() is checked against the worker's policy. The page's policy runs again as a post-request check on the response the worker returns. The CSP specification notes that this check "verifies that the page can load the response. That is, that a Service Worker hasn't substituted a file which would violate the page's CSP." In practice this matters when a worker returns a response whose URL (after following redirects) is not allowed by the page.

The worker decides the document's policy

The most important PWA-specific consequence is easy to miss. When a service worker answers a navigation, the browser creates the document's policy container from the response the worker returned. Three cases:

  1. Pass-through (respondWith(fetch(event.request))): the network response's headers, including your CSP, reach the document unchanged.
  2. Cached response (respondWith(caches.match(...))): the headers stored with the response are used. If the server sent CSP when the response was cached, the page gets that policy, including any nonce it contained.
  3. Constructed response (new Response(body, init)): only the headers you pass exist. Streaming templates, offline pages built from strings, and responses re-wrapped to fix redirects all lose CSP, COOP, COEP and every other security header unless you add them.

The same mechanism lets a worker add headers the server does not send. Projects such as coi-serviceworker use it to enable cross-origin isolation on static hosts where you cannot configure COOP and COEP. The corollary is that a compromised worker can remove your CSP from every page it serves. CSP protects against injection into pages; it does not protect against a malicious service worker. That is the job of the controls on the Service Worker Security page.

Directives that matter for PWAs

The table lists the directives a PWA should set deliberately, with the fallback chain the CSP Level 3 "get fetch directive fallback list" algorithm defines. If a directive is absent, the browser uses the first present directive in its chain.

Directive Governs Fallback chain PWA notes
worker-src new Worker(), new SharedWorker(), navigator.serviceWorker.register() worker-src → child-src → script-src → default-src Set it explicitly. MDN: Chrome 59, Firefox 58, Safari 15.5.
script-src Scripts, and the fallback for script-src-elem, script-src-attr and workers script-src → default-src Use nonces or hashes with 'strict-dynamic'.
manifest-src <link rel="manifest"> fetch manifest-src → default-src A blocked manifest makes the app non-installable. MDN: Chrome 40, Firefox 41, Safari 11.
connect-src fetch(), XHR, WebSocket, EventSource, sendBeacon() connect-src → default-src In the worker's policy, every request the worker makes is a connect-src request, including image and font pass-through.
img-src Images, favicons img-src → default-src Include data: and blob: only if you use them.
font-src, style-src, media-src Fonts, styles, audio and video Each → default-src (style-src-elem/-attr → style-src) Checked in the page before the worker sees the request.
frame-src Iframes frame-src → child-src → default-src Payment and identity iframes need entries.
frame-ancestors Who may embed your pages None Header only; ignored in <meta>. Use 'none' or an allowlist.
form-action Form submission targets None Limits where injected forms can post.
base-uri <base href> None 'none' or 'self'; injected <base> retargets relative script URLs.
object-src <object>, <embed> object-src → default-src 'none'.
upgrade-insecure-requests Rewrites http: subresource URLs to https: None Migration aid; see Security & Privacy.
require-trusted-types-for Enables Trusted Types enforcement for DOM XSS sinks None 'script' is the only value.
trusted-types Which Trusted Types policy names may be created None 'none', names, 'allow-duplicates'.
report-to Reporting endpoint name None Needs a Reporting-Endpoints header.

worker-src

worker-src restricts the URLs that may be loaded as a Worker, SharedWorker or ServiceWorker. A registration that the page policy blocks rejects with SecurityError and produces a violation report. Three details matter:

  • The fallback chain skips script-src-elem. The CSP spec notes that "script-src-elem is not used as a fallback for the worker-src directive. The worker-src checks still fall back on the script-src directive." MDN's compatibility notes add that Chrome 59 and later skip the deprecated child-src directive, so do not rely on child-src to restrict workers.
  • 'strict-dynamic' allows script-created workers. Worker and service worker requests are script-like destinations, so when worker-src is absent, the script-src rules apply. The script-directive pre-request check allows any non-parser-inserted request if the directive contains 'strict-dynamic', and a register() call is not parser-inserted. Under a typical strict policy without worker-src, any script that runs (including an injected one that got past your other defenses) may register any same-origin URL.
  • Paths are allowed. A host-source with a path matches exactly when the path does not end in /. worker-src https://app.example.com/sw.js permits your worker and nothing else, which blocks the "register a JSONP endpoint as a worker" escalation described on the Service Worker Security page. Add any dedicated worker scripts you use, and blob: only if you create workers from blobs.
A worker-src that allows exactly two worker scripts
Content-Security-Policy: worker-src https://app.example.com/sw.js https://app.example.com/workers/search.js

manifest-src

The web app manifest is fetched through the <link rel="manifest"> element, checked against manifest-src (falling back to default-src only, not script-src or connect-src). The fetch is made in CORS mode without credentials unless the link has crossorigin="use-credentials", which you need only if the manifest sits behind cookie authentication. A default-src 'none' policy without a manifest-src silently makes the app non-installable: DevTools reports that no manifest was found, and the console shows the CSP violation. Use manifest-src 'self' for a same-origin manifest. See the Web App Manifest section for the manifest itself.

connect-src in the page and in the worker

In the page's policy, connect-src covers fetch(), XHR, WebSocket and EventSource connections, plus navigator.sendBeacon(). Include every API origin, your analytics endpoint and your reporting endpoint.

In the worker's policy, connect-src covers every network request the worker initiates: fetch(), cache.add(), cache.addAll() and the fetches behind Workbox strategies. That includes requests that are images, fonts or scripts from the page's point of view, because inside the worker they are all fetch() calls. The spec mechanics: CSP picks the effective directive from the request's destination (image maps to img-src, font to font-src, and an empty destination to connect-src), and the Fetch standard's Request constructor, which fetch(event.request) runs internally, does not copy destination. The worker's copy of the request therefore has an empty destination and is checked against connect-src, even though event.request.destination still reads "image". If your site applies one CSP to every response (a common CDN or framework default), sw.js receives a policy written for documents, and a connect-src 'self' in it blocks the worker's pass-through of cross-origin images even though the page's img-src allows them. Write a separate policy for the worker, as shown in The service worker's own policy.

Strict CSP for offline-first apps

A strict CSP is one where scripts are allowed only by nonce or hash, not by host allowlists. Google's guidance at web.dev recommends:

Nonce-based strict CSP
Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
Hash-based strict CSP
Content-Security-Policy:
  script-src 'sha256-{HASHED_INLINE_SCRIPT}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

The https: and 'unsafe-inline' entries are fallbacks for very old browsers: CSP Level 2 browsers ignore 'unsafe-inline' when a nonce or hash is present, and browsers that support 'strict-dynamic' (Chrome 52, Firefox 52, Safari 15.4 per MDN) ignore host-sources and 'unsafe-inline' in script-src. Nonces must be at least 128 bits of cryptographically secure randomness, base64-encoded, and newly generated for every response.

That last requirement is where PWAs diverge.

Why nonces and cached app shells do not mix

A nonce is a per-response secret that an attacker who injects markup cannot guess. A service worker that caches the HTML and replays it turns the nonce into a constant: every launch serves the same document with the same nonce attribute and the same header. The nonce stops being unpredictable. An attacker who has seen it once (in the cached HTML, which any script on the origin can read through caches.match(), or in a network response) can reuse it in stored injections for as long as the cache entry lives.

Strict CSP still works mechanically with a cached nonce, which makes the degradation easy to miss. Pick one of these architectures instead:

Approach How it works Use when
Hash-based shell (recommended) The shell HTML is static. Inline scripts are allowed by 'sha256-…' hashes computed at build time; external scripts are loaded by a hashed bootstrap script under 'strict-dynamic', or listed by hash with SRI. SPAs and app-shell PWAs whose HTML does not change per user.
Network-first HTML with nonces Navigations go to the network, where the server renders HTML with a fresh nonce. The cached copy is used only as an offline fallback, and the offline fallback itself uses a hash-based policy. Server-rendered MPAs. See SPA vs MPA PWAs.
Worker-side nonce rewriting The cached shell contains a fixed marker token; the worker generates a fresh nonce per navigation and rewrites both the attribute and the header. Rarely. If any user-controlled content ever ends up in the cached shell, an attacker can include the marker token and have the worker nonce their script.

Building a hash-based policy

Compute hashes of every inline <script> in the built HTML and emit the header value. CSP hashes are over the exact text between <script> and </script>, including whitespace:

scripts/csp-hashes.mjs
// Node.js build step: hash inline scripts in dist/index.html and write the CSP
// for the shell to dist/_csp.txt (read by your server or edge configuration).
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

const html = await readFile("dist/index.html", "utf8");

// Inline scripts only: <script> without a src attribute. JSON data blocks
// (type="application/json") are not executed and need no hash.
const inline = [...html.matchAll(/<script(?![^>]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/gi)]
  .filter(([, attrs]) => !/type=["']?application\/(ld\+)?json/i.test(attrs))
  .map(([, , body]) => body);

if (inline.length === 0) {
  throw new Error("No inline bootstrap script found; hash-based CSP needs at least one.");
}

const hashes = inline.map(
  (body) => `'sha256-${createHash("sha256").update(body, "utf8").digest("base64")}'`,
);

const policy = [
  `script-src ${hashes.join(" ")} 'strict-dynamic' https: 'unsafe-inline'`,
  "object-src 'none'",
  "base-uri 'none'",
  "worker-src https://app.example.com/sw.js",
  "manifest-src 'self'",
  "connect-src 'self' https://api.example.com",
  "img-src 'self' data: https://images.example-cdn.com",
  "style-src 'self'",
  "font-src 'self'",
  "frame-ancestors 'none'",
  "form-action 'self'",
  "require-trusted-types-for 'script'",
  "trusted-types app-html sw-url dompurify",
  "report-to csp",
].join("; ");

await writeFile("dist/_csp.txt", policy + "\n");
console.log(`CSP with ${hashes.length} script hash(es) written to dist/_csp.txt`);

The shell itself then contains a single inline bootstrap that loads the application bundle. Because the bootstrap is trusted by hash and creates the <script> element with createElement(), 'strict-dynamic' extends trust to the bundle and to everything the bundle imports:

dist/index.html (excerpt)
<script>
  // Hashed at build time. Loads the versioned bundle; 'strict-dynamic' trusts it.
  const s = document.createElement("script");
  s.src = "/assets/app.3f9a1c.js";
  s.type = "module";
  document.head.append(s);
</script>

CSP Level 3 also allows hashes to match external scripts that carry SRI metadata: a <script src integrity="sha256-…"> whose integrity hash appears in script-src is allowed. MDN lists this in Chrome 59, Firefox 116 and Safari 15.6. It removes the need for a bootstrap if you prefer listing each entry point.

Serving the shell with headers the worker controls

Because the worker's response determines the document's policy, make the worker the single place that attaches security headers to navigation responses, whether they come from the network, the cache, or a template:

src/sw/secure-navigation.js
// The policy for the static shell is generated at build time (see csp-hashes.mjs)
// and inlined into the worker bundle, so it updates atomically with the shell.
import { SHELL_CSP } from "./generated/shell-csp.js";

const SHELL_HEADERS = {
  "Content-Type": "text/html; charset=utf-8",
  "Content-Security-Policy": SHELL_CSP,
  "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "X-Content-Type-Options": "nosniff",
  "Reporting-Endpoints": 'csp="https://app.example.com/reports/csp"',
};

/**
 * Re-wraps a shell response so it always carries the expected headers, even if
 * the cached entry was stored without them or has been tampered with.
 */
export function withShellHeaders(response) {
  const headers = new Headers(response.headers);
  for (const [name, value] of Object.entries(SHELL_HEADERS)) headers.set(name, value);
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;
  event.respondWith(
    (async () => {
      const cached = await caches.match("/index.html");
      if (cached) return withShellHeaders(cached);
      try {
        return await fetch(event.request); // network response keeps server headers
      } catch {
        const offline = await caches.match("/offline.html");
        return offline ? withShellHeaders(offline) : Response.error();
      }
    })(),
  );
});

Setting headers on the constructed response does not help if the cached body has been replaced; combine this with the body verification on the Service Worker Security page.

A meta-delivered policy as a second layer

A <meta http-equiv="Content-Security-Policy"> element inside the shell HTML travels with the HTML bytes, so a verified cached shell carries its own policy even if a code path forgets the header. If both a header policy and a meta policy exist, the browser enforces both, and a load must pass every policy. Limitations from the spec: frame-ancestors, report-uri and sandbox are not supported in meta, Content-Security-Policy-Report-Only cannot be delivered by meta, and the policy applies only to content after the element, so place it at the top of <head>.

The service worker's own policy

Serve sw.js with a policy written for the worker. The directives that do anything in a service worker global scope are few:

Directive Effect inside the worker
default-src Fallback for everything below. Start from 'none'.
script-src importScripts() URLs (classic workers). With 'self' only, a cross-origin import throws. eval() and new Function() are blocked unless you allow 'unsafe-eval' (don't). 'wasm-unsafe-eval' (Chrome 97, Firefox 102, Safari 16 per MDN) allows WebAssembly compilation without allowing eval().
connect-src Every fetch(), cache.add() and cache.addAll() the worker performs, whatever the destination looks like from the page's point of view.
require-trusted-types-for / trusted-types Trusted Types enforcement for importScripts() and other worker sinks.
report-to / report-uri Where the worker's violations are reported. Send Reporting-Endpoints on sw.js too.
upgrade-insecure-requests Upgrades the worker's own http: fetches.

Document-only directives such as frame-ancestors, form-action, base-uri, style-src and img-src have no effect in a worker, because a worker has no document to embed, no forms and no elements that load images or styles.

Example worker policies

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'; report-to csp
Reporting-Endpoints: csp="https://app.example.com/reports/csp"

The worker imports nothing from other origins and only fetches from its own origin. Any cross-origin fetch, including one smuggled in through a poisoned message handler, fails and reports.

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 https://images.example-cdn.com https://fonts.gstatic.com; report-to csp
Reporting-Endpoints: csp="https://app.example.com/reports/csp"

Every origin whose requests the worker intercepts and forwards must appear in connect-src. Requests the worker does not intercept (because the fetch handler returns without calling respondWith()) are not affected by the worker's policy.

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:; report-to csp
Reporting-Endpoints: csp="https://app.example.com/reports/csp"

If the worker forwards every request for pages that load from many third parties, a narrow connect-src breaks them. https: keeps the worker from making insecure requests while leaving origin restrictions to the page's policy, which still runs its pre-request check before the worker sees anything.

Configure the worker's headers explicitly in your server or host, and make sure the generic site-wide CSP does not also apply to sw.js (two policies are both enforced; the stricter one wins for each request):

nginx.conf
location = /sw.js {
    add_header Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com; report-to csp" always;
    add_header Reporting-Endpoints 'csp="https://app.example.com/reports/csp"' always;
    add_header Cache-Control "no-cache" always;
    add_header X-Content-Type-Options "nosniff" always;
    types { text/javascript js; }
}
_headers
/sw.js
  Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com; report-to csp
  Reporting-Endpoints: csp="https://app.example.com/reports/csp"
  Cache-Control: no-cache
  X-Content-Type-Options: nosniff
server/sw-route.mjs
import express from "express";
import path from "node:path";

export const swRouter = express.Router();

swRouter.get("/sw.js", (req, res) => {
  res.set({
    "Content-Type": "text/javascript; charset=utf-8",
    "Cache-Control": "no-cache",
    "X-Content-Type-Options": "nosniff",
    "Content-Security-Policy":
      "default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com; report-to csp",
    "Reporting-Endpoints": 'csp="https://app.example.com/reports/csp"',
  });
  // Register this router BEFORE any middleware that sets a document CSP.
  res.sendFile(path.resolve("dist/sw.js"));
});

Trusted Types

Trusted Types remove DOM-based XSS at the source: once enforced, dangerous sinks such as innerHTML, outerHTML, insertAdjacentHTML(), document.write(), DOMParser.parseFromString(), script.src, script.text, string arguments to setTimeout(), and eval() reject plain strings and require typed objects (TrustedHTML, TrustedScript, TrustedScriptURL) produced by a policy you define. A strict CSP stops injected scripts from running; Trusted Types stop your own code from creating the injection in the first place. Google's Trusted Types guide recommends deploying them together.

Enforcing Trusted Types
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html sw-url dompurify
  • require-trusted-types-for 'script' turns on enforcement for all script-related sinks.
  • trusted-types lists the policy names your code may create. 'none' forbids creating any policy, and 'allow-duplicates' permits creating two policies with the same name (avoid it).
  • A policy named default is called automatically for any sink that receives a string, which is useful as a migration shim that logs and sanitizes.
  • The 'trusted-types-eval' keyword in script-src allows eval() only with TrustedScript values when Trusted Types are enforced. MDN lists it in Chrome 145, Firefox 148 and Safari 26.

MDN's compatibility data lists Trusted Types (the API and both CSP directives) in Chrome and Edge 83, Firefox 148 and Safari 26.

PWA-specific sinks: register() and importScripts()

Two sinks matter only to PWAs:

  • navigator.serviceWorker.register(scriptURL) takes a TrustedScriptURL. With enforcement on, passing a string rejects with a TypeError. MDN lists enforcement in Chrome 140 and Safari 26; Firefox has it in preview builds.
  • importScripts(...urls) inside a worker whose own policy enforces Trusted Types takes TrustedScriptURL values. MDN lists enforcement in Chrome 138 and Safari 26.

One policy module covers the page side, including a sanitizer-backed HTML policy:

src/trusted-types.js
import DOMPurify from "dompurify";

const tt = window.trustedTypes;

/** Service worker and dedicated worker URLs: an explicit allowlist. */
const WORKER_URLS = new Set(["/sw.js", "/workers/search.js"]);

export const swUrlPolicy = tt?.createPolicy("sw-url", {
  createScriptURL(input) {
    const url = new URL(input, location.origin);
    if (url.origin === location.origin && WORKER_URLS.has(url.pathname) && !url.search) {
      return url.href;
    }
    throw new TypeError(`Blocked worker URL: ${input}`);
  },
});

/** HTML from untrusted sources goes through DOMPurify. */
export const htmlPolicy = tt?.createPolicy("app-html", {
  createHTML(input) {
    return DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false });
  },
});

export function registerServiceWorker() {
  const url = swUrlPolicy ? swUrlPolicy.createScriptURL("/sw.js") : "/sw.js";
  return navigator.serviceWorker.register(url, { scope: "/" });
}

export function setUntrustedHTML(element, html) {
  element.innerHTML = htmlPolicy ? htmlPolicy.createHTML(html) : DOMPurify.sanitize(html);
}

DOMPurify itself creates a Trusted Types policy named dompurify when it can, which is why that name appears in the trusted-types directive above. The worker side needs a policy only if the worker's own CSP enforces Trusted Types and the worker calls importScripts():

sw.js (classic worker with Trusted Types enforced by its own CSP)
const importPolicy = self.trustedTypes?.createPolicy("sw-imports", {
  createScriptURL(input) {
    const url = new URL(input, self.location.origin);
    // Only our own versioned helper bundles.
    if (url.origin === self.location.origin && url.pathname.startsWith("/sw/")) return url.href;
    throw new TypeError(`Blocked import: ${input}`);
  },
});

const toURL = (u) => (importPolicy ? importPolicy.createScriptURL(u) : u);
importScripts(toURL("/sw/routes.3f9a1c.js"), toURL("/sw/db.77b2e0.js"));

For that worker, the policy header would be default-src 'none'; script-src 'self'; connect-src 'self'; require-trusted-types-for 'script'; trusted-types sw-imports; report-to csp.

Rolling out Trusted Types

Enforcing Trusted Types on an existing app breaks every string assignment to a sink, including those inside libraries. Roll out in report-only mode first:

Report-only Trusted Types alongside an enforced CSP
Content-Security-Policy: script-src 'sha256-…' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-to csp

Collect the reports (each includes the sink and a sample of the value), fix the call sites or route them through policies, then move the directives into the enforced header.

CSP violation reporting

A policy you do not monitor decays: new third-party scripts get added, violations break features silently, and injection attempts go unnoticed. CSP has two reporting mechanisms:

Mechanism Syntax Payload Status
report-to + Reporting-Endpoints Reporting-Endpoints: csp="https://…/csp" and report-to csp in the policy application/reports+json, batched array of reports with type: "csp-violation" Current. MDN: report-to in Chrome 70, Firefox 149, Safari 16.4; Reporting-Endpoints in Chrome 96, Firefox 130, Safari 16.4.
report-uri report-uri https://…/csp in the policy application/csp-report, one JSON object per violation under "csp-report" Deprecated but still widely supported.

The CSP specification says that if report-to is present, report-uri is ignored, and suggests sending both for compatibility:

Reporting with both mechanisms
Reporting-Endpoints: csp="https://app.example.com/reports/csp"
Content-Security-Policy: script-src 'sha256-…' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-uri https://app.example.com/reports/csp; report-to csp

Add 'report-sample' to script-src (or style-src) to include the first 40 characters of a blocked inline script or style in the report. MDN lists it in Chrome 59, Firefox 63 and Safari 15.4.

Reports from the service worker

The worker's policy reports independently of the page's. A violation inside the worker (a blocked importScripts(), a fetch() outside connect-src) is reported to the endpoint named in the worker's policy, using the Reporting-Endpoints header on sw.js. If you send reporting headers only on HTML, worker violations appear in the worker's DevTools console and nowhere else.

A collector that accepts both formats

server/csp-reports.mjs
import express from "express";

export const reportsRouter = express.Router();

// Both content types, with a size cap: report endpoints are unauthenticated
// and receive traffic from every browser that loads your pages.
reportsRouter.post(
  "/reports/csp",
  express.json({ type: ["application/reports+json", "application/csp-report", "application/json"], limit: "64kb" }),
  (req, res) => {
    const reports = normalize(req.body);
    for (const report of reports) {
      // Drop noise from browser extensions, which inject scripts into pages.
      if (/^(chrome|moz|safari(-web)?)-extension:/.test(report.sourceFile || report.blockedURL || "")) continue;
      req.log?.info({ csp: report }, "CSP violation");
    }
    res.status(204).end();
  },
);

function normalize(body) {
  // Reporting API: an array of { type, url, body: { ... } }.
  if (Array.isArray(body)) {
    return body
      .filter((r) => r?.type === "csp-violation" && r.body)
      .map((r) => ({
        documentURL: r.body.documentURL,
        blockedURL: r.body.blockedURL,
        effectiveDirective: r.body.effectiveDirective,
        disposition: r.body.disposition,
        sourceFile: r.body.sourceFile,
        lineNumber: r.body.lineNumber,
        sample: r.body.sample,
      }));
  }
  // Legacy report-uri: { "csp-report": { ... } } with hyphenated keys.
  const legacy = body?.["csp-report"];
  if (!legacy) return [];
  return [
    {
      documentURL: legacy["document-uri"],
      blockedURL: legacy["blocked-uri"],
      effectiveDirective: legacy["effective-directive"] || legacy["violated-directive"],
      disposition: legacy.disposition,
      sourceFile: legacy["source-file"],
      lineNumber: legacy["line-number"],
      sample: legacy["script-sample"],
    },
  ];
}

The Reporting API delivers reports as POST requests in cors mode with credentials mode same-origin and Content-Type: application/reports+json, which is not a CORS-safelisted type. A same-origin endpoint needs nothing extra. A collector on another origin (a shared reports.example.com, or a third-party service) must answer the CORS preflight: respond to OPTIONS with Access-Control-Allow-Origin for your app origins, Access-Control-Allow-Methods: POST and Access-Control-Allow-Headers: Content-Type, or every report is silently dropped. Legacy report-uri reports are sent differently and do not need the preflight.

The report endpoint must be reachable when the page is online; reports generated offline are queued by the browser for a limited time and may be dropped. The reporting fetch is not intercepted by your service worker, so you do not need a route for it. Include the report endpoint's origin in the page's connect-src only if your own code posts to it (for example from a securitypolicyviolation listener); browser-generated reports are not subject to CSP.

Listening for violations in the page

securitypolicyviolation events fire on the document (and bubble from elements) for every violation of the page's policies, which is useful for tests and for surfacing violations in your own monitoring:

src/csp-monitor.js
document.addEventListener("securitypolicyviolation", (event) => {
  // Same fields as the report body, available synchronously.
  const detail = {
    directive: event.effectiveDirective,
    blocked: event.blockedURI,
    source: event.sourceFile,
    line: event.lineNumber,
    disposition: event.disposition, // "enforce" or "report"
    sample: event.sample,
  };
  window.dispatchEvent(new CustomEvent("app:csp-violation", { detail }));
});

COOP, COEP, CORP and cross-origin isolation

Three response headers control how your documents relate to other origins. They are not part of CSP, but they are usually deployed with it, and they interact with service worker caching in ways that break offline apps if you are not prepared.

Header Values Effect
Cross-Origin-Opener-Policy (COOP) unsafe-none (default), same-origin-allow-popups, same-origin, noopener-allow-popups Whether your top-level document shares a browsing context group with cross-origin windows it opens or that open it. same-origin severs window.opener in both directions for cross-origin windows. noopener-allow-popups (Chrome 131, Safari 18.4 per MDN) severs the opener relationship even for same-origin openers.
Cross-Origin-Embedder-Policy (COEP) unsafe-none (default), require-corp, credentialless Whether cross-origin subresources must opt in. require-corp blocks no-cors cross-origin responses without CORP or CORS. credentialless (Chrome 96, Firefox 119, not Safari, per MDN) instead sends no-cors cross-origin requests without credentials and does not require CORP.
Cross-Origin-Resource-Policy (CORP) same-origin, same-site, cross-origin Set on resources: who may embed this response in no-cors mode. Enforced under COEP; same-origin and same-site are also enforced without COEP.

A document is cross-origin isolated (self.crossOriginIsolated === true) when it has COOP same-origin and COEP require-corp or credentialless. Isolation unlocks SharedArrayBuffer (and therefore WebAssembly threads), performance.measureUserAgentSpecificMemory(), and higher-resolution timers, as described in web.dev's COOP and COEP guide. MDN lists COOP and COEP in Chrome 83, Firefox 79 and Safari 15.2, and crossOriginIsolated in Chrome 87, Firefox 72 and Safari 15.2.

For PWAs that need isolation but cannot live with COOP same-origin (it breaks OAuth and payment popups that rely on window.opener), Chromium ships Document-Isolation-Policy, which lets a single document become cross-origin isolated without COOP or COEP, backed by process isolation. Chrome Platform Status lists it as shipped in Chrome 137 on desktop and Chrome 146 on Android. The explainer defines the values none, isolate-and-require-corp and isolate-and-credentialless. Mozilla's standards position is positive, while WebKit's review is still open with portability and device-independence concerns and no implementation, so treat it as Chromium-only.

How COEP interacts with service worker caching

Four mechanisms affect a PWA that enables COEP:

  1. Responses from the worker are CORP-checked against the page's policy. The Fetch standard runs the cross-origin resource policy check on responses "coming from the network and responses coming from the service worker," because "request's client and the service worker can have different embedder policies." If a page with COEP: require-corp requests a cross-origin image in no-cors mode and your worker answers with a cached opaque response that has no Cross-Origin-Resource-Policy: cross-origin header, the page gets a network error, even though the response is in your cache.
  2. The worker has its own embedder policy. Like CSP, the worker's COEP comes from the headers on the sw.js response. Its own fetch() calls are checked against it, and self.crossOriginIsolated inside the worker reflects it.
  3. Cache Storage checks opaque entries against the worker's policy. The service worker specification's match() algorithm rejects with a TypeError when an opaque stored response fails the CORP check for the calling context. Enabling COEP on sw.js can therefore make existing cache entries unreadable.
  4. credentialless changes what gets cached. Under COEP: credentialless, cross-origin no-cors requests go out without cookies. A response cached under the old policy may be a personalized, credentialed version; the new policy fetches anonymous versions under the same cache key.

The robust fix for all four is to stop caching opaque responses: request cross-origin assets in CORS mode (crossorigin="anonymous" on elements, mode: "cors" in the worker) from servers that send Access-Control-Allow-Origin. CORS responses satisfy COEP without CORP. For assets you serve yourself from another origin, add Cross-Origin-Resource-Policy: cross-origin (or same-site).

When you turn on COEP for an existing PWA, ship a worker update that deletes runtime caches containing opaque responses, and send COEP on sw.js in the same deploy as on your HTML:

src/sw/coep-migration.js
const RUNTIME_CACHES = ["images-v3", "fonts-v2"];

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      for (const name of RUNTIME_CACHES) {
        const cache = await caches.open(name);
        const requests = await cache.keys();
        await Promise.all(
          requests.map(async (request) => {
            let response;
            try {
              response = await cache.match(request);
            } catch {
              // match() rejects for opaque entries that fail the CORP check
              // under this worker's COEP: they are unusable, remove them.
              await cache.delete(request);
              return;
            }
            if (response && response.type === "opaque") await cache.delete(request);
          }),
        );
      }
    })(),
  );
});

Because the worker's navigation response determines the document's policies, a worker that serves the shell from cache must include COOP and COEP on that response, or the page silently loses isolation when served offline. Add them in the same withShellHeaders() function shown earlier:

src/sw/isolation-headers.js
export const ISOLATION_HEADERS = {
  "Cross-Origin-Opener-Policy": "same-origin",
  "Cross-Origin-Embedder-Policy": "require-corp",
  "Cross-Origin-Resource-Policy": "same-origin",
};

On the first visit, the page is not controlled by the worker, so its isolation comes from the server's headers. If you rely on a worker to add isolation headers (as coi-serviceworker does on static hosts), the first load is not isolated and the app must reload once after the worker takes control.

Symptoms and fixes

Symptom Cause Fix
Cached images fail with net::ERR_BLOCKED_BY_RESPONSE after enabling COEP Opaque cached responses without CORP Cache in CORS mode, add CORP on your asset origins, purge old runtime caches
cache.match() rejects with TypeError Worker's own COEP blocks opaque entries Same as above
crossOriginIsolated is true online and false offline Worker-served navigation lacks COOP/COEP Add isolation headers to every navigation response the worker builds
OAuth or payment popup cannot talk back to the opener COOP same-origin severs window.opener Use same-origin-allow-popups (no isolation) or Document-Isolation-Policy in Chromium, or switch the flow to redirects or postMessage through a same-origin callback
Third-party iframe fails under COEP Iframe document lacks CORP/COEP Ask the provider for COEP-compatible embeds, or use credentialless iframes in Chromium

Example policies

The following policies are complete starting points. Replace the example origins with yours, then tighten.

Headers for the shell HTML (also set by the worker)
Content-Security-Policy: script-src 'sha256-…' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; worker-src https://app.example.com/sw.js; manifest-src 'self'; connect-src 'self' https://api.example.com; img-src 'self' data: https://images.example-cdn.com; style-src 'self'; font-src 'self'; frame-ancestors 'none'; form-action 'self'; require-trusted-types-for 'script'; trusted-types app-html sw-url dompurify; report-uri https://app.example.com/reports/csp; report-to csp
Reporting-Endpoints: csp="https://app.example.com/reports/csp"
Cross-Origin-Opener-Policy: same-origin-allow-popups
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
Headers for /sw.js
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com https://images.example-cdn.com; report-uri https://app.example.com/reports/csp; report-to csp
Reporting-Endpoints: csp="https://app.example.com/reports/csp"
Headers for network-rendered HTML
Content-Security-Policy: script-src 'nonce-rAnd0m128bitBase64==' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; worker-src https://www.example.com/sw.js; manifest-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'self'; form-action 'self'; report-to csp
Reporting-Endpoints: csp="https://www.example.com/reports/csp"

Navigations are network-first. The offline fallback page is static and served by the worker with a hash-based policy of its own, since it cannot carry a fresh nonce. Do not cache nonce-bearing HTML for replay; if you cache pages for offline reading, serve them through withShellHeaders()-style code that replaces the policy with one that allows no inline script and no external script except by hash.

Headers for the shell HTML
Content-Security-Policy: script-src 'sha256-…' 'strict-dynamic'; object-src 'none'; base-uri 'none'; worker-src https://shop.example.com/sw.js; manifest-src 'self'; connect-src 'self' https://api.example.com https://analytics.example.net; img-src 'self' data: https://images.example-cdn.com https://analytics.example.net; frame-src https://pay.example-psp.com; frame-ancestors 'none'; form-action 'self' https://pay.example-psp.com; report-to csp
Reporting-Endpoints: csp="https://shop.example.com/reports/csp"
Cross-Origin-Opener-Policy: same-origin-allow-popups

With 'strict-dynamic', the analytics loader is trusted because your hashed bootstrap adds it; connect-src and img-src still need the analytics collection origin. The payment provider's iframe and redirect targets appear in frame-src and form-action. See Payments.

Headers for the shell HTML and /sw.js
Content-Security-Policy: script-src 'sha256-…' 'strict-dynamic' 'wasm-unsafe-eval'; object-src 'none'; base-uri 'none'; worker-src 'self' blob:; manifest-src 'self'; connect-src 'self'; frame-ancestors 'none'; report-to csp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
Reporting-Endpoints: csp="https://studio.example.com/reports/csp"

'wasm-unsafe-eval' allows WebAssembly compilation. blob: in worker-src is included because many threaded WASM runtimes spawn their workers from blob URLs; remove it if yours does not. The worker must send the same COEP so that its own fetches and its cache reads follow the same rules as the page.

Testing and rolling out a policy

A safe rollout for an existing PWA:

  1. Inventory. List every origin the app loads from, in the page and in the worker. DevTools' Network panel with the worker's requests included, plus your worker's routing table, is the starting point.
  2. Evaluate. Paste candidate policies into CSP Evaluator, which flags missing object-src, base-uri and bypassable allowlists.
  3. Report-only. Deploy the policy as Content-Security-Policy-Report-Only on HTML and on sw.js. Leave it for at least one full release cycle so that installed users on older workers report too.
  4. Enforce, and keep a stricter candidate in report-only (for example Trusted Types) for the next iteration.
  5. Test offline. Service-worker-served responses are where policies disappear. Automate the check.

Automated tests with Playwright

This test fails if any page load produces a violation, and verifies that the worker-served (offline) navigation still carries the policy and isolation headers:

tests/csp.spec.mjs
import { test, expect } from "@playwright/test";

test.describe("Content Security Policy", () => {
  test("no violations and CSP survives offline navigation", async ({ page, context }) => {
    const violations = [];
    await page.exposeFunction("reportViolation", (v) => violations.push(v));
    await page.addInitScript(() => {
      document.addEventListener("securitypolicyviolation", (e) => {
        window.reportViolation({ directive: e.effectiveDirective, blocked: e.blockedURI });
      });
    });

    // First load: from the network. Wait until the worker controls the page.
    const online = await page.goto("/");
    expect(online.headers()["content-security-policy"]).toContain("'strict-dynamic'");
    await page.evaluate(async () => {
      await navigator.serviceWorker.ready;
      if (!navigator.serviceWorker.controller) {
        await new Promise((r) => navigator.serviceWorker.addEventListener("controllerchange", r, { once: true }));
      }
    });

    // Second load: offline, so the worker must answer from cache.
    await context.setOffline(true);
    const offline = await page.reload();
    expect(offline.fromServiceWorker()).toBe(true);
    const headers = offline.headers();
    expect(headers["content-security-policy"]).toContain("script-src");
    expect(headers["content-security-policy"]).toContain("object-src 'none'");
    expect(headers["x-content-type-options"]).toBe("nosniff");

    // An injected inline script must be blocked and reported.
    await page.evaluate(() => {
      try {
        const s = document.createElement("script");
        s.textContent = "window.__injected = true"; // a Trusted Types sink
        document.body.append(s);
      } catch {
        // Trusted Types enforcement threw: the injection was blocked earlier.
      }
    });
    expect(await page.evaluate(() => window.__injected)).toBeUndefined();

    await context.setOffline(false);
    const expectedDirectives = new Set(["script-src-elem", "require-trusted-types-for"]);
    const unexpected = violations.filter((v) => !expectedDirectives.has(v.directive));
    expect(unexpected).toEqual([]);
  });
});

Assigning textContent to a script element is itself a Trusted Types sink, so with Trusted Types enforced the assignment throws and reports a require-trusted-types-for violation instead of a script-src-elem one. The test accepts either, and fails on any other violation. See Automated Testing for running service worker tests in CI.

Debugging in DevTools

  • Console: each violation logs the blocked URL and the directive. Worker violations appear in the worker's console, reachable from Application → Service workers in Chromium, about:debugging in Firefox, and the Develop menu in Safari.
  • Chromium Issues panel: groups CSP and Trusted Types violations with the offending source location.
  • Chromium Application panel, Frames: shows the top frame's CSP, COOP and COEP values and whether it is cross-origin isolated, which is the quickest way to spot an offline navigation that lost its headers.
  • Network panel: the response headers of a worker-served navigation are the headers the worker constructed. Compare them with the server's.

Browser support

Feature Chrome / Edge Firefox Safari
worker-src ✅ 59 / 79 ✅ 58 ✅ 15.5
manifest-src ✅ 40 / 79 ✅ 41 ✅ 11
'strict-dynamic' ✅ 52 / 79 ✅ 52 ✅ 15.4
Hashes for external scripts (SRI) ✅ 59 / 79 ✅ 116 ✅ 15.6
'wasm-unsafe-eval' ✅ 97 ✅ 102 ✅ 16
script-src-elem / script-src-attr ✅ 75 / 79 ✅ 108 ✅ 15.4
CSP in workers (from the worker's response) ✅ 56 / 79 ✅ 50 ✅ 10
report-to directive ✅ 70 / 79 ✅ 149 ✅ 16.4
Reporting-Endpoints header ✅ 96 ✅ 130 ✅ 16.4
Trusted Types (require-trusted-types-for, trusted-types) ✅ 83 ✅ 148 ✅ 26
'trusted-types-eval' ✅ 145 ✅ 148 ✅ 26
Trusted Types enforced for register() ✅ 140 🧪 ✅ 26
Integrity-Policy (blocked-destinations=(script)) ✅ 138 ⚠️ 145 ✅ 26
COOP / COEP ✅ 83 ✅ 79 ✅ 15.2
COEP credentialless ✅ 96 ✅ 119 ❌
COOP noopener-allow-popups ✅ 131 ❌ ✅ 18.4
Document-Isolation-Policy ✅ 137 desktop, 146 Android ❌ ❌

Support data as of September 2026, from MDN's compatibility data and Chrome Platform Status; check caniuse for live data. 🧪 Firefox enforces Trusted Types for register() only in preview builds. ⚠️ Firefox 145 enforces Integrity-Policy but ignores its reporting endpoints and logs violations to the console instead.

The Integrity-Policy header complements CSP for supply-chain defense: Integrity-Policy: blocked-destinations=(script) blocks any script request in the document that lacks integrity metadata, and blocks no-cors script requests outright. Deploy it first as Integrity-Policy-Report-Only with an endpoints=(…) parameter naming a Reporting-Endpoints entry.

Common pitfalls

  • One site-wide CSP applied to sw.js, whose connect-src blocks the worker's pass-through of cross-origin images, fonts and API calls.
  • No worker-src under a 'strict-dynamic' policy, allowing any script-created worker registration.
  • default-src 'none' without manifest-src, making the app non-installable.
  • Nonce-based CSP on a cached shell, which replays one nonce forever.
  • new Response(html) in the worker without headers, producing pages with no CSP, COOP or COEP when served offline.
  • Reporting headers only on HTML, so worker violations are never collected.
  • frame-ancestors in a <meta> policy, where it is ignored.
  • Enabling COEP without purging opaque cache entries, breaking cached images offline.
  • COOP same-origin on an app that uses OAuth or payment popups relying on window.opener.
  • Trusted Types enforced without a policy for the worker URL, so register() rejects and the app silently loses offline support.

Further reading

On this site

External references