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.jsresponse). The page'sworker-srcdecides whether a worker may be registered; the worker's ownscript-srcandconnect-srcdecide 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'inscript-srcand noworker-src, any script-created worker is allowed. A path-level value such ashttps://app.example.com/sw.jsblocks rogue worker registrations. - Trusted Types (Chrome 83, Firefox 148, Safari 26 per MDN) also cover
navigator.serviceWorker.register()andimportScripts(), so worker URLs must pass through a policy. - Report with
report-toplusReporting-Endpoints, keepreport-urifor older engines, and send reporting headers onsw.jstoo. - 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 carryCross-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:
- Pass-through (
respondWith(fetch(event.request))): the network response's headers, including your CSP, reach the document unchanged. - 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. - 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-elemis not used as a fallback for theworker-srcdirective. Theworker-srcchecks still fall back on thescript-srcdirective." MDN's compatibility notes add that Chrome 59 and later skip the deprecatedchild-srcdirective, so do not rely onchild-srcto restrict workers. 'strict-dynamic'allows script-created workers. Worker and service worker requests are script-like destinations, so whenworker-srcis absent, thescript-srcrules apply. The script-directive pre-request check allows any non-parser-inserted request if the directive contains'strict-dynamic', and aregister()call is not parser-inserted. Under a typical strict policy withoutworker-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.jspermits 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, andblob:only if you create workers from blobs.
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:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
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:
// 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:
<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:
// 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¶
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.
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.
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):
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; }
}
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.
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-typeslists 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
defaultis 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 inscript-srcallowseval()only withTrustedScriptvalues 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 aTrustedScriptURL. With enforcement on, passing a string rejects with aTypeError. 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 takesTrustedScriptURLvalues. MDN lists enforcement in Chrome 138 and Safari 26.
One policy module covers the page side, including a sanitizer-backed HTML policy:
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():
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:
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-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¶
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:
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:
- 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-corprequests a cross-origin image inno-corsmode and your worker answers with a cached opaque response that has noCross-Origin-Resource-Policy: cross-originheader, the page gets a network error, even though the response is in your cache. - The worker has its own embedder policy. Like CSP, the worker's COEP comes from the headers on the
sw.jsresponse. Its ownfetch()calls are checked against it, andself.crossOriginIsolatedinside the worker reflects it. - Cache Storage checks opaque entries against the worker's policy. The service worker specification's
match()algorithm rejects with aTypeErrorwhen an opaque stored response fails the CORP check for the calling context. Enabling COEP onsw.jscan therefore make existing cache entries unreadable. credentiallesschanges what gets cached. UnderCOEP: credentialless, cross-originno-corsrequests 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:
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);
}),
);
}
})(),
);
});
Navigation responses need the isolation headers too¶
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:
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.
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
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.
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.
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:
- 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.
- Evaluate. Paste candidate policies into CSP Evaluator, which flags missing
object-src,base-uriand bypassable allowlists. - Report-only. Deploy the policy as
Content-Security-Policy-Report-Onlyon HTML and onsw.js. Leave it for at least one full release cycle so that installed users on older workers report too. - Enforce, and keep a stricter candidate in report-only (for example Trusted Types) for the next iteration.
- 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:
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:debuggingin 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, whoseconnect-srcblocks the worker's pass-through of cross-origin images, fonts and API calls. - No
worker-srcunder a'strict-dynamic'policy, allowing any script-created worker registration. default-src 'none'withoutmanifest-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-ancestorsin a<meta>policy, where it is ignored.- Enabling COEP without purging opaque cache entries, breaking cached images offline.
- COOP
same-originon an app that uses OAuth or payment popups relying onwindow.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
- Security & Privacy overview: secure contexts, HTTPS, the threat model and the header baseline
- Service Worker Security: serving rules, cache poisoning, kill switches, CORS and redirects
- Registration & Scope: CSP and Trusted Types at registration time
- Handling Fetch Events: constructing responses in the worker
- Streaming Responses: composing HTML in the worker, where headers are easy to lose
- App Shell Model
- Cache Storage API: opaque responses and quota
- Permissions: Permissions Policy for powerful features
External references
- Content Security Policy Level 3 (W3C Editor's Draft) and CSP3 on w3.org
- MDN: Content Security Policy guide and Content-Security-Policy header reference
- MDN: worker-src and manifest-src
- Mitigate XSS with a strict CSP and Trusted Types (web.dev)
- Trusted Types specification and MDN: Trusted Types API
- MDN: Reporting API and CSPViolationReportBody
- Making your website cross-origin isolated using COOP and COEP and Why you need cross-origin isolated (web.dev)
- MDN: Cross-Origin-Embedder-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy
- MDN: Integrity-Policy
- CSP Evaluator