Skip to content
White / wsuites

Practitioner 3 min read

A strict CSP on a static site, without giving up interaction

How this site keeps script-src free of 'unsafe-inline' while still running theme switching, scroll effects, and a protected contact form.

Published

Most Content Security Policies fail the same way. Someone adds the header, half the page breaks, 'unsafe-inline' goes into script-src, and the policy stops being a policy: it becomes decoration. An attacker who finds an injection point can still execute script, which is the thing the header existed to prevent.

This site ships the strict version instead. Here is the whole policy:

default-src 'self';
script-src 'self' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self';
font-src 'self';
connect-src 'self' https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests

No 'unsafe-inline' in script-src, no 'unsafe-eval', no wildcard origins. Everything else follows from that one constraint.

The rule that forces good structure

If inline script is forbidden, every line of JavaScript has to live in a file served from your own origin. That sounds like a restriction. In practice it is a design rule that pushes you somewhere better: behaviour ends up in a small number of named files with real boundaries, instead of scattered through markup as one-off handlers.

Concretely, all client behaviour here lives in four files under /public/scripts/. Each one guards against double-initialisation, because with client-side routing the same script can be asked to start more than once:

(() => {
  if (window.portfolioFxLoaded) return;
  window.portfolioFxLoaded = true;
  // ...
})();

Markup then carries data, never logic. A component that needs localized runtime strings serializes them into an attribute and the script reads them back:

<form data-contact-form data-contact-strings={JSON.stringify(messages)}>
function readStrings(form) {
  try {
    return { ...DEFAULT_STRINGS, ...JSON.parse(form.dataset.contactStrings) };
  } catch {
    return DEFAULT_STRINGS;
  }
}

That parse is wrapped in a try on purpose. Reading your own attribute should never be able to take the form down.

The one thing a strict CSP makes genuinely harder

Theme switching. To avoid a flash of the wrong palette, the theme has to be resolved before the first paint, which is exactly what an inline script in <head> is normally used for.

The replacement is an external script that is deliberately render-blocking:

<script is:inline src="/scripts/theme.js"></script>

No defer, no async. It is same-origin, already in the connection, and a few hundred bytes, so it resolves the theme and applies it to <html> before the body renders:

apply(storedTheme() ?? systemTheme());

This is the one place where “make it render-blocking” is the correct answer. Everywhere else, defer is right.

Verifying it instead of trusting it

A policy that is never checked drifts. The build script for this site walks the generated HTML and fails if an inline script appears anywhere:

for (const match of html.matchAll(/<script\b([^>]*)>/g)) {
  const src = match[1].match(/\bsrc="([^"]+)"/)?.[1];
  if (!src) {
    assert(
      /\btype="application\/ld\+json"/.test(match[1]),
      `${route} contains inline executable JavaScript`,
    );
  }
}

JSON-LD is the only exception, because type="application/ld+json" is data, not an executable script, and CSP treats it accordingly.

The same script asserts that script-src still contains 'self' and still does not contain 'unsafe-inline'. If someone loosens the header later to make something work, the build fails and they have to make the decision consciously rather than accidentally.

What this costs

Honestly: some convenience. Third-party embeds that inject inline script will not work without an explicit exception, and you have to think about where each piece of behaviour lives.

What you get is a policy that actually holds. style-src still allows inline styles, which is a real remaining gap (it is there because the framework emits scoped style attributes, and closing it would need per-build hashes). I would rather state that openly than pretend the policy is perfect.

The rule I would keep from all of this: a security header you have not tested against your own build output is a guess, not a control.