⌨️ Web development

Content Security Policy: set it up without breaking your site

How Content Security Policy works: key directives, why to avoid unsafe-inline, nonces, hashes and Report-Only mode to roll it out step by step.

Content Security Policy (CSP) is an HTTP header you use to tell the browser where your page may load resources from and execute code. If an attacker manages to inject a script into the page, a well-configured CSP stops the browser from running it. It does not replace proper input handling, but it is a very effective second line of defence against cross-site scripting.

The problem is that a hastily written CSP breaks the site: blocked scripts, missing styles, widgets that disappear. This article shows how to build one step by step.

The structure of a policy

A CSP is a list of directives, each with its allowed sources, separated by semicolons:

Content-Security-Policy: default-src 'self'; img-src 'self' data:; script-src 'self'

The most commonly used directives:

Directive Controls
default-src fallback for all directives not specified
script-src JavaScript files and inline scripts
style-src stylesheets and inline styles
img-src images
font-src web fonts
connect-src requests made with fetch, XHR, WebSocket
frame-src content embedded in iframes
frame-ancestors who may frame your page
form-action addresses forms may submit data to
base-uri allowed values for the base element
object-src plugins such as object and embed

The most common sources are 'self' (the page's own origin), 'none' (nothing), a specific domain such as https://cdn.example.com, and schemes such as data: or https:. Keywords go in single quotes; domains do not.

The problem with inline scripts

By default, a CSP with script-src blocks all inline scripts: <script> blocks in the HTML and attributes such as onclick. That is precisely the policy's strength, because XSS attacks almost always inject inline code.

The 'unsafe-inline' shortcut re-enables everything, but it cancels out most of the protection. There are two proper alternatives.

Nonces

The server generates a random value that is different on every response and inserts it both in the header and in legitimate scripts:

Content-Security-Policy: script-src 'nonce-R4nd0mV4lu3'
<script nonce="R4nd0mV4lu3">
  initMenu();
</script>

An attacker injecting a script does not know the nonce, so their code is blocked. A nonce only works if it is truly unpredictable and regenerated on every request: a fixed nonce written into the template protects nothing.

Hashes

For static sites, where no server generates values per request, you can allow an inline script through the SHA-256 hash of its content:

Content-Security-Policy: script-src 'self' 'sha256-<base64 hash>'

If the script's content changes by even one character, the hash no longer matches and must be updated. Many build tools can calculate it automatically.

The simplest solution

Often the best route is to move inline code into external files and replace onclick attributes with addEventListener. Then script-src 'self' is all you need.

Rolling it out with Report-Only

CSP has a trial mode: the Content-Security-Policy-Report-Only header applies the same rules but blocks nothing; it only reports violations in the browser console and, optionally, to a collection endpoint.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

A practical path:

  1. Publish a strict policy in Report-Only mode.
  2. Browse every part of the site and collect violations for a few days.
  3. For each violation decide: is it a legitimate resource to allow, or code to rewrite?
  4. When the legitimate reports have stopped, switch to the real header.
  5. Keep collecting reports afterwards, to catch regressions.

The report-uri directive is still very widespread; the newer one is report-to, which relies on a separate header to define the collection endpoints.

A starting policy

For a site that serves everything from its own origin, with no third-party services:

Content-Security-Policy:
  default-src 'self';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'self';
  upgrade-insecure-requests

(In the real header it all goes on one line.) object-src 'none' and base-uri 'self' close two often-forgotten attack routes; upgrade-insecure-requests asks the browser to load resources referenced over HTTP via HTTPS instead.

Third-party services

Analytics, maps, embedded videos and external fonts require adding their domains to the appropriate directives. Every allowed domain is trust you grant: if that domain also hosts content uploaded by other users, an attacker could use it to slip their own script through. Allow only what you need and, where possible, prefer hosting resources on your own domain.

Common mistakes

  • Using 'unsafe-inline' and 'unsafe-eval' "to make everything work", defeating the policy.
  • Forgetting connect-src and breaking API calls.
  • Allowing entire schemes such as https: in script-src: it amounts to trusting the whole web.
  • Putting the policy in a meta tag and expecting frame-ancestors or reporting to work: they are not supported in that form.

In short

  • CSP tells the browser where the page may load resources from and execute code.
  • Avoid 'unsafe-inline': use random nonces, hashes, or move code into external files.
  • Roll it out with Content-Security-Policy-Report-Only and analyse the violations.
  • Always include object-src 'none', base-uri and frame-ancestors.
  • Every allowed third-party domain is a risk: keep the list to what is necessary.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also