Fixing Content Security Policy Violations From Inline Scripts

A Content Security Policy without 'unsafe-inline' in script-src is one of the most effective protections a static site can have against injected scripts — and switching it on usually breaks something. The console fills with "Refused to execute inline script because it violates the following Content Security Policy directive", and pages lose their theme toggle, their analytics, their copy buttons. The pressure is to add 'unsafe-inline' back, which removes most of the protection the policy was meant to provide.

This guide removes inline script violations from an Eleventy documentation and marketing site after deploying a strict policy in report-only mode. It shows how to collect and group reports, the five sources of inline scripts found, and the fix for each — hashes for what must stay inline, external files for the rest, and no event handler attributes. It belongs to Security Headers and Hardening for Static Sites; writing the initial policy is covered in Writing a Content Security Policy for a Static Site, and the Astro-specific automation in Hash-Based CSP for Inline Scripts in Astro.

Step 1: Deploy in Report-Only Mode and Collect

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://plausible.io;
  style-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self';
  report-to csp; report-uri https://example.com/csp-report
Reporting-Endpoints: csp="https://example.com/csp-report"

The report endpoint is a small edge function that stores reports with their page URL, directive and script-sample (the first 40 characters of the inline code). A week of traffic produced 18,400 reports. Grouping by script-sample and directive reduced them to eleven distinct sources.

CSP report volume per distinct inline script source over one week Horizontal bars. The theme initialisation script in the head 7,900 reports. onclick attributes on copy buttons 4,300. An analytics snippet pasted in the layout 3,100. A cookie notice inline script 1,800. Inline style attributes from a Markdown plugin 1,100. Browser extensions 200, ignored. Distinct inline script sources by report volume Theme init script 7900 onclick on copy buttons 4300 Analytics snippet 3100 Cookie notice script 1800 Inline style attributes 1100 Browser extensions 200
18,400 reports grouped by script sample and directive; extension reports come from readers' browsers, not the site.

Reports from browser extensions (samples starting with chrome-extension or containing extension-specific code) were filtered out: they are not the site's code and cannot be fixed by the site.

Source 1: The Theme Script That Must Stay Inline

The dark-mode script must run before first paint, so it has to be inline in <head> — moving it to a file would bring back the flash of the wrong theme. It gets a hash. Because the build produces the HTML, the build can compute the hash:

// eleventy.config.js — after build, collect hashes of all inline executable scripts
import { createHash } from 'node:crypto';
eleventyConfig.on('eleventy.after', async ({ results }) => {
  const hashes = new Set();
  for (const { content } of results)
    for (const [, body] of content.matchAll(/<script(?![^>]*\btype="(?!module|text\/javascript)[^"]*")[^>]*>([\s\S]*?)<\/script>/g))
      if (body.trim()) hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`);
  await writeHeaders([...hashes]);   // writes the script-src line into _headers
});

The regex skips JSON-LD and other non-executable types. The hash covers the script's exact bytes, including whitespace, so the hash is computed from the built output rather than the template — a minifier that runs after would change it, so the HTML minifier runs before this step. Accessible Dark Mode Toggles for Static Sites shows the script.

Source 2: Event Handler Attributes

Copy buttons were rendered as <button onclick="copyCode(this)"> by a Markdown plugin. Inline event handlers cannot be allowed by hash in script-src without 'unsafe-hashes', which is a weaker setting best avoided. The plugin was changed to emit <button class="copy" data-copy>, and one external script attaches a delegated listener:

document.addEventListener('click', (e) => {
  const btn = e.target.closest('[data-copy]');
  if (btn) navigator.clipboard.writeText(btn.closest('figure').querySelector('code').innerText);
});

A grep of the built HTML for on[a-z]+= now runs in CI and fails on any event handler attribute.

Source 3: A Pasted Analytics Snippet

The analytics vendor's instructions said to paste an inline snippet that defines a queue function and loads the script. The vendor also offers a plain <script defer src="…"> tag with configuration in data- attributes, which needs no inline code at all; the snippet was replaced with it, and the vendor's domain was already in script-src. For custom events, the site's own external script calls the vendor's API after it loads.

A small inline script read a cookie and showed the notice. It moved into the site's main external script file. Since that file is loaded with defer, the notice appears a moment after first paint rather than with it; to avoid layout shift, the notice is positioned fixed at the bottom of the viewport, so its appearance moves nothing.

Source 5: Inline Styles

style-src 'self' also blocks style="…" attributes. A Markdown plugin added style="text-align:center" to table cells from Markdown alignment syntax. Replacing them with classes (.align-center) in the plugin's output fixed 1,100 reports. Inline SVG diagrams with style attributes were the harder case on this site; they were allowed with 'unsafe-inline' in style-src only, after weighing that style injection is far less dangerous than script injection, and documented as a known exception.

Fix applied to each source of inline code A matrix of five sources. The theme script must run before paint, so it stays inline with a build-time hash. onclick attributes were replaced by a delegated listener in an external file. The analytics snippet was replaced by the vendor's script tag with data attributes. The cookie notice script moved to the external bundle. Inline style attributes from Markdown became classes, with inline SVG styles allowed in style-src as a documented exception. The fix for each source Source Must be inline? Fix Theme init yes, before paint sha256 hash at build onclick handlers no delegated listener Analytics snippet no vendor script tag Cookie notice no moved to bundle style attributes SVG only classes; SVG exception
Only one inline script remains, and it is allowed by hash, not by unsafe-inline.

Keeping Hashes in Sync

A hash allows exactly one version of a script. Edit the theme script by one character and the hash no longer matches; with the policy enforced, the script stops running and every page flashes the wrong theme. Generating the header from the built HTML in the same build, as above, means the hash always matches what ships. The failure mode to guard against is a header file generated in one step and HTML changed in a later one — a post-processing minifier, an HTML rewriter at the edge, or a CDN feature that injects scripts.

Order of build steps so CSP hashes match the shipped HTML Five steps. Render the pages. Minify the HTML. Compute hashes from the final inline scripts. Write the Content-Security-Policy header with those hashes. Deploy without any later step that rewrites HTML, and turn off CDN features that inject scripts. Build order that keeps hashes valid Render pages generator Minify HTML final bytes Hash inline scripts from output Write header _headers / config Deploy as-is no edge rewriting
Any HTML change after the hash step invalidates the hash; the CI test catches it before deploy.

Cloudflare's automatic features that modify HTML — email address obfuscation, Rocket Loader and some analytics injection — each add inline scripts the build never saw. On this site, email obfuscation was the culprit for a burst of reports on pages with mailto links after the policy went live; turning it off for the zone and using a contact form instead resolved it.

Step 2: Enforce

After a week with no reports from the site's own code, the header switched from Content-Security-Policy-Report-Only to Content-Security-Policy, keeping the reporting endpoints. A CI test loads ten pages with Playwright and fails on any securitypolicyviolation event, so a new inline script cannot reach production:

await page.addInitScript(() => document.addEventListener('securitypolicyviolation', (e) => (window.__csp ??= []).push(e.violatedDirective + ' ' + e.sample)));
// …navigate…
expect(await page.evaluate(() => window.__csp ?? [])).toEqual([]);

Measured Impact

MeasureReport-only weekAfter fixes (enforced)Tool
CSP reports from site code / week18,2000Report endpoint
Inline executable scripts on a page41 (hashed)Built HTML
Inline event handler attributes2,600 (across pages)0CI grep
'unsafe-inline' in script-src—Never addedHeader
Mozilla Observatory scoreBA+Observatory scan

Pitfalls & Rollback

  • Adding 'unsafe-inline' to make errors stop. It undoes most of the policy's value.
  • Hashing before minification. Hash the final bytes.
  • Nonces on a static CDN. They need per-response uniqueness; use hashes.
  • Counting extension reports as site bugs. Filter them out.
  • Rollback: switch the header back to report-only; the site keeps working while reports continue.

Conclusion

CSP violations from inline scripts on a static site come from a short list: a script that must run before paint, event handler attributes, pasted vendor snippets, small helper scripts and inline styles from plugins. Hashing the one script that must stay inline at build time, and moving everything else to external files and classes, took the site from 18,200 weekly reports to none under an enforced policy without 'unsafe-inline'.

FAQ

Why does my CSP block inline scripts?

A script-src directive without unsafe-inline, a nonce or a matching hash blocks every inline script element and every inline event handler attribute such as onclick. That is the protection CSP is designed to give, because injected scripts are usually inline. Each legitimate inline script needs to be allowed explicitly or moved to a file.

Are hashes or nonces better for a static site?

Hashes. A nonce must be unique per response, which a static file served from a CDN cannot provide without an edge function rewriting every page. A hash is computed from the script's content at build time and stays valid until the script changes, which fits static output exactly.

Do JSON-LD script blocks need to be in the CSP?

No. Script elements with a non-JavaScript type such as application ld+json are not executed, so script-src does not apply to them and browsers do not report them. Only executable inline scripts and event handler attributes need hashes or removal.

How do I find every inline script that violates the policy?

Deploy the policy as Content-Security-Policy-Report-Only with a report-to or report-uri endpoint first, and collect reports for a week. Each report names the violated directive, the page, and for inline scripts a sample of the code. Group them by sample to find each distinct source.

Filed underFixes