A Content Security Policy is the browser's last line against cross-site scripting: even if an attacker gets markup into the page, scripts the policy does not allow will not run. For a single-page app built with Vite, the hard parts are not the directives but the details around them: where the policy lives, what it breaks in development, and which libraries quietly try to evaluate code. This post walks through the policy of a finance planning PWA, including Trusted Types, and the one library setting that would otherwise produce a violation on every page load.
The app is the finance planner, a local-first PWA (React, Vite, served as static files). Its data lives in IndexedDB on the device, which makes DOM-based XSS the attack that matters most: a script injected into the page can read everything without touching the server.
The policy, directive by directive
The policy is built at build time from the environment, because the API and Keycloak origins differ between deployments (shortened from vite.config.ts):
const csp = [
"default-src 'self'",
"script-src 'self'",
// Toasts inject a <style> tag and popups position themselves with inline styles.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
`connect-src 'self' ${origins}`,
// oidc-client-ts may renew the session in a hidden iframe on Keycloak's origin.
`frame-src ${auth ?? "'none'"}`,
"worker-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"require-trusted-types-for 'script'",
"trusted-types default",
].join("; ");
The important line is script-src 'self': no 'unsafe-inline', no 'unsafe-eval', no CDN. Only files served from the app's own origin run. connect-src limits where data can be sent to the app itself, the API and the identity provider, so a script that did get in could not post the IndexedDB contents elsewhere with fetch.
style-src 'unsafe-inline' is a common trade-off. The toast library injects a <style> element and popovers position themselves with inline style attributes. Inline styles cannot run code, but they can be used for data exfiltration tricks with CSS selectors. Removing them means every library has to set styles through the CSSOM rather than style attributes or <style> tags, because a static app has no server to hand out nonces. A 'sha256-...' hash would cover a fixed inline block such as the splash screen <style> in index.html, which also relies on 'unsafe-inline' today, but not styles injected by libraries or style attributes. With script-src strict and connect-src limited, it is the lower-risk directive to relax.
A meta tag, applied only to the build
The app is hosted as static files, so the simplest place for a policy that depends on build variables is the HTML itself. A small Vite plugin injects it (also shortened):
const contentSecurityPolicy = ({ api, auth }: { api?: string; auth?: string }): Plugin => ({
name: "content-security-policy",
apply: "build",
transformIndexHtml: () => [
{ tag: "meta", attrs: { "http-equiv": "Content-Security-Policy", content: csp }, injectTo: "head-prepend" },
],
});
apply: "build" keeps it out of the dev server, which injects inline scripts for hot module replacement and React Fast Refresh. The policy would block them. injectTo: "head-prepend" matters too: a policy in a <meta> tag only applies to content that comes after it, so it must be the first thing in <head>.
The cost of testing only in production builds is that a violation can slip past pnpm dev. Running pnpm build && pnpm preview before a release is the check that catches it.
What a meta tag cannot do
The CSP specification ignores three directives when the policy comes from a <meta> element: frame-ancestors, report-uri and sandbox. Two of those mattered here.
Clickjacking protection has to be a header. frame-ancestors is what stops another site from loading the app in an invisible frame. The host's configuration sends it, alongside the older X-Frame-Options for browsers that predate it:
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" }
A page can have several policies, and the browser enforces every one of them; a request must pass all. So the header adding only frame-ancestors does not loosen the meta policy, it adds one rule the meta tag could not carry.
Violations have no report endpoint. Without report-uri, the browser blocks silently. The page reports instead, by listening for securitypolicyviolation and sending the blocked directive through the app's own error endpoint, described in the post on client error reporting. A violation is either an attack or a policy that is too strict, and both are worth knowing about.
Trusted Types: no HTML from strings at all
CSP stops scripts from untrusted origins, but not code that the app's own scripts build from strings: element.innerHTML = userInput is the classic DOM XSS. Trusted Types close that gap. With require-trusted-types-for 'script', the browser refuses plain strings at dangerous sinks (innerHTML, script.src, eval and others) and only accepts objects created by a policy.
The second directive, trusted-types default, says which policies may exist: one, named default. The default policy is special: the browser calls it automatically when a string reaches a sink. This app's policy implements exactly one conversion:
window.trustedTypes.createPolicy("default", {
createScriptURL: (url: string) => {
if (new URL(url, location.href).origin === location.origin) return url;
throw new TypeError(`Script URL not allowed: ${url}`);
},
});
Script URLs from the app's own origin pass, which is what registering the service worker needs. There is no createHTML and no createScript, so any attempt to turn a string into HTML or code throws. React builds the DOM with createElement and text nodes; only dangerouslySetInnerHTML would reach an HTML sink, and the app never uses it or innerHTML. If something does reach one, the error is reported like any other.
Two details make this safe to ship. The policy is created in main.tsx right after the imports, before the app renders, and because the directive allows a single policy named default, any later attempt to create another one fails. And the function returns early when window.trustedTypes does not exist, so browsers without Trusted Types support simply ignore the directive and run the app normally; check current browser support before relying on it as your only defense.
The library that probes for eval on every load
With a policy this strict, one library would report a violation on every page load: script-src blocking an eval, although nothing in the app calls eval. The source is Zod 4, the validation library.
Zod can compile schemas into fast parsers with new Function. To know whether it may, it probes once by calling new Function("") inside a try. Under a strict policy the call throws, Zod catches it and falls back to its interpreter, and validation works. But the browser reports a violation for the attempt even though the exception is caught, so every load sent a report about nothing.
Zod has a setting for this, jitless: true, and its documentation on CSP suggests z.config({ jitless: true }). The catch is timing. Zod finds out whether it may compile when a schema is made, and schemas are made as modules load. In a Vite build split into chunks, there is no reliable place in the app's code that runs before every module that defines a schema.
The answer is in how Zod stores its configuration: on globalThis.__zod_globalConfig, shared by every copy of Zod, and read when Zod loads. A classic script runs before the app's module scripts, so a tiny file in public/ sets it first:
// public/csp.js, loaded by index.html before the app's code
globalThis.__zod_globalConfig = Object.assign(globalThis.__zod_globalConfig || {}, { jitless: true });
It has to be a file, not an inline <script>: the policy allows only scripts from the app's origin, so inline code would itself be blocked.
What the policy does not protect against
- Compromised dependencies.
script-src 'self'trusts every file the build produces, including code from npm packages. A malicious package is served from the app's own origin and runs. - Inline styles. As above,
'unsafe-inline'for styles leaves CSS-based tricks possible. - Browsers without Trusted Types. On those, the DOM XSS protection falls back to React's escaping and code review.
- The dev server. It runs with no policy, so the first place a new violation shows up may be a production build.
Summary
| Problem | What I did |
|---|---|
| Origins differ per deployment | Policy built at build time by a Vite plugin |
| HMR needs inline scripts | Plugin applied to builds only; check with build and preview |
frame-ancestors ignored in meta |
Sent as a response header, alongside X-Frame-Options |
No report-uri in meta |
Page reports securitypolicyviolation through the error endpoint |
| DOM XSS through string sinks | Trusted Types with a single default policy for same-origin script URLs |
Zod's eval probe reported on every load |
jitless set on globalThis by a script that runs before the app |
If you run a strict CSP on a single-page app, I'd love to compare notes on which libraries fought it. My email is on the home page.