Content-Security-Policy Deep Dive for NZ sites: unsafe-inline, unsafe-eval, nonces, hashes and report-only mode

Quick answer

Content-Security-Policy (CSP) tells the browser which sources of script, style, image, font, and connection are allowed on your site, and blocks everything else. The five building blocks worth understanding are default-src, script-src, style-src, frame-ancestors, and form-action — every other directive either falls back to default-src or fine-tunes what those five allow. The dangerous words to recognise are unsafe-inline, unsafe-eval, and * (wildcard); the modern replacements are nonces and hashes.

For the baseline header set and copy-paste fixes for the most common server stacks, see the Security Headers Explained guide. For a ten-second CSP check on your own site, run the Free Website Security Check.

How CSP actually works

Every time the browser loads a page, your server can send back a Content-Security-Policy header. The browser then enforces that policy for the entire page load: any script, style, image, font, frame, or network request that does not match an allowed source is blocked before it runs (1).

A policy is a list of directives. Each directive names a category of resource (script-src, img-src, font-src, connect-src, frame-src, …) and the sources it is allowed to come from. Sources can be:

  • 'self' — your own origin (https://yourschool.nz)
  • 'none' — nothing is allowed
  • 'unsafe-inline' — any inline script/style is allowed (defeats most of what CSP is for)
  • 'unsafe-eval' — the browser may run eval(), new Function(), setTimeout(string, …) (1)
  • https: / data: / blob: — any URL of that scheme (still wide)
  • An exact origin like https://www.youtube.com — only this origin
  • A nonce like 'nonce-abc123' — only inline code that carries the matching nonce attribute
  • A hash like 'sha256-H8b8aH...=' — only inline code whose contents hash exactly to that value

A request must match a directive that applies to its type; if no directive matches, the browser blocks it. This is why default-src matters — it is the fallback for every directive you do not set explicitly (2).

CSP does not fix vulnerabilities in your code. It limits what an attacker can do if something else (an XSS bug, a stale dependency, a vulnerable third-party widget) goes wrong. Think of it as a seatbelt — cheap, does not stop the crash, but changes the outcome.

The five directives that matter on a typical NZ school or small-business site

Most policy-writing guides list fifteen or twenty directives and leave you to pick. For a typical school or small-business site, only five matter; everything else either falls back to default-src or addresses an edge case you will probably never hit.

default-src — the catch-all

Content-Security-Policy: default-src 'self'

default-src is the fallback for every directive you do not set explicitly: if you do not set script-src, the browser uses default-src for scripts. Setting default-src 'self' means "by default, only allow things from my own domain". This is the right starting point for almost every site (2).

script-src — where CSP actually earns its keep

Content-Security-Policy: script-src 'self' https://www.googletagmanager.com

This is the directive that stops cross-site scripting. Anything in <script> tags, inline event handlers (onclick="…"), and JavaScript URLs (javascript:…) must come from one of the listed sources (1)(3).

The dangerous source here is 'unsafe-inline'. If your policy includes script-src 'unsafe-inline', the browser will execute any inline <script> tag regardless of what else your policy says. That is functionally the same as having no CSP at all for XSS purposes (3). If you see unsafe-inline in script-src, that part of the policy is doing nothing for security.

style-src — CSS and inline styles

Content-Security-Policy: style-src 'self' 'unsafe-inline'

style-src is more permissive in practice than script-src because a lot of legitimate tooling writes inline styles. unsafe-inline is generally accepted for style-src but should never appear in script-src (2).

frame-ancestors — the modern X-Frame-Options replacement

Content-Security-Policy: frame-ancestors 'none'

This directive replaces the older X-Frame-Options: DENY header. It stops your page being loaded inside an iframe on a different origin, which is the technique behind clickjacking attacks (4). 'none' is the strictest; 'self' allows your own pages to frame each other but no one else.

form-action — where forms can submit to

Content-Security-Policy: form-action 'self' https://forms.example.com

form-action controls where <form> elements are allowed to submit. This is the only directive that stops a cross-site request forgery (CSRF) attempt where the form lives on your page but submits to an attacker. Set it to your own origin plus any third-party form endpoints you actually use (1).

What unsafe-inline and unsafe-eval actually allow

These two source expressions are the most common reason a CSP fails to do its job.

unsafe-inline allows the browser to execute any inline <script> or <style>. Inline scripts are exactly the kind of payload a cross-site scripting (XSS) attack injects. Allowing them undoes most of CSP's value (3). The modern replacements are nonces and hashes (below).

unsafe-eval allows the browser to execute strings as code at runtime via eval(), new Function(), setTimeout("…", …), and similar. This matters because a lot of obfuscated or generated JavaScript uses these patterns. It is also sometimes required by older JavaScript bundlers and analytics libraries (1).

If a vendor template, theme, or plugin instructs you to add 'unsafe-inline' 'unsafe-eval' to your CSP, treat it as a code-quality signal — that component is almost certainly using inline scripts or runtime code generation that you cannot easily audit.

Nonces — the modern replacement for unsafe-inline

A nonce is a random, single-request secret that the server generates for each page load and includes in both the CSP header and a matching attribute on every legitimate inline script. The browser only executes inline scripts that carry the matching nonce value (5).

A minimal nonce-based policy looks like this:

Content-Security-Policy: script-src 'self' 'nonce-RandomBase64Value=='

And every legitimate inline script on the page looks like this:

<script nonce="RandomBase64Value==">
  // legitimate inline script
</script>

Two important properties:

  • The nonce must be different every request. Reusing the same nonce allows an attacker who has injected one script to inject every subsequent script. Most server frameworks have a built-in helper for this — Express has res.locals.cspNonce, Django has a CSP middleware, nginx can do it with sub_filter and a random value.
  • Nonces do not work with <script> tags loaded from third-party CDNs unless those tags are rewritten. If you embed a third-party analytics snippet as <script nonce="…">, the third-party tag will work, but if the third party later adds their own inline script, it will be blocked.

Hashes — the same idea, but the secret is the script's contents

A hash is a SHA-256, SHA-512, or a SHA-256 of the script's exact text. You put the hash in the CSP, and the browser only executes inline scripts whose contents hash to that exact value (5).

Content-Security-Policy: script-src 'self' 'sha256-H8b8aH/3pQyN5w8t1u9oA2z5x0r3yL8qM9oA4z5x0r='

Hashes are useful when you cannot rotate a nonce per request — for example, in a fully static site or a CDN-cached HTML page. They are also useful as an audit tool: any inline script on the page either matches a hash in your policy or does not run, so you can list exactly which scripts your policy allows.

The downside is that any change to the inline script breaks the hash. Even a single space difference between what your source code says and what the browser sees will fail the check. For most teams, nonces are easier to operate.

Wildcards — what * actually allows

A wildcard source (*) matches every URL of the requested type. This is almost never what you want (2):

  • script-src * — any script from anywhere on the internet. Functionally no CSP for scripts.
  • style-src * — any stylesheet. Same problem, lower stakes.
  • frame-ancestors * — any site can frame yours. Defeats clickjacking protection.
  • img-src * — any image source. Often acceptable, but be aware it allows tracking pixels.
  • connect-src * — any fetch/XHR/WebSocket destination. Wide open.
  • default-src * — wide open everywhere.

There are also subdomain wildcards like https://*.example.com and port wildcards like https://example.com:*. They are narrower than * but still broader than naming exact origins. Use specific origins whenever you can.

Content-Security-Policy Deep Dive for NZ sites — unsafe-inline, unsafe-eval, nonces, hashes and wildcards

Per-directive copy-paste guidance

Below are the per-directive values that cover most NZ school and small-business sites. Build your policy up from default-src, then loosen the directives that need it.

The minimal starting policy

Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'

This policy allows nothing by default, locks <base> to your own origin, locks forms to your own origin, blocks framing, and blocks plugins. It will break most real sites, but it is the right starting point (1).

Adding scripts and styles

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

This is the recommended baseline for a school or small-business site that does not use a CDN, third-party analytics, or embedded videos. unsafe-inline only appears in style-src, where it is generally accepted because most legitimate CSS tooling relies on inline styles (2).

Adding third-party scripts (analytics, fonts, video)

For a site that loads Google Analytics, Google Fonts, and embeds YouTube:

Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com; frame-src https://www.youtube.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

Each third party is named explicitly. Wildcards are not used.

Adding a nonce (for sites with inline scripts)

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-RandomBase64Value=='; style-src 'self' 'unsafe-inline'; …

The --style-src 'unsafe-inline' stays in place because CSS nonces are much harder to operate. The nonce in script-src must rotate per request — see the nonce section above for the operational details (5).

Report-only mode — how to roll out a CSP without breaking the site

Report-only mode tells the browser to send violation reports instead of blocking anything. It is the right way to roll out a new policy:

  1. Send the policy as Content-Security-Policy-Report-Only (not Content-Security-Policy) — the browser will not block anything.
  2. Add a report-uri or report-to directive pointing to a URL you control (a free hosted report collector, your own server, or a logging endpoint) (1).
  3. Watch the reports for a week or two. You will see which resources the policy would have blocked.
  4. For each blocked resource, decide: do we need this resource (loosen the directive), or is it leftover from a plugin we no longer use (delete it)?
  5. Once the report volume is small and you understand every line, switch the header name from -Report-Only to the enforcing Content-Security-Policy.

The Mozilla Observatory and the report-uri.io service are the most common free collectors. For NZ-hosted sites that want to keep data in the country, run the collector as a small endpoint on the same server or behind the same CDN.

Never leave report-only mode on forever. Report-only mode does not protect the site — it only logs what protection would have done. Once the violations are understood, switch to enforcement.

nginx and Apache configuration

Below are the two most common server stacks for NZ schools and small businesses, with the CSP set as a header.

nginx

Add inside your server { … } block:

add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-RandomBase64=='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

The always flag tells nginx to add the header to every response, including error responses. If you are rolling out report-only, change Content-Security-Policy to Content-Security-Policy-Report-Only and add a report-uri:

add_header Content-Security-Policy-Report-Only "default-src 'self'; …; report-uri https://reports.yourschool.nz/csp-violations" always;

Reload with nginx -s reload (or sudo systemctl reload nginx on systemd hosts).

Apache

Add inside your <VirtualHost>, or in an .htaccess in the document root:

<IfModule mod_headers.c>
  Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-RandomBase64=='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
</IfModule>

For report-only mode, use set Content-Security-Policy-Report-Only instead. If the header disappears on Apache, confirm mod_headers is loaded (apache2ctl -M | grep headers).

Common CSP mistakes to avoid

From running the free scanner across New Zealand school and small-business sites, the most frequent missteps are:

  • Adding 'unsafe-inline' to script-src "to make it work". Defeats the entire policy for XSS purposes. Use a nonce or hashes instead.
  • Adding 'unsafe-eval' because a vendor template asked for it. Most modern JavaScript does not need eval. Ask the vendor why before allowing it.
  • Wildcard * in script-src or frame-ancestors. Functionally no CSP for that resource type.
  • Reporting endpoint set to a URL that returns 404. The browser will keep sending reports; nothing will happen to them. Confirm the endpoint returns 200.
  • CSP set at the origin but stripped by a CDN. Cloudflare, Varnish, and most reverse proxies overwrite or drop headers set at the origin. Always verify through the CDN edge, not just from the origin server (see the Security Headers Explained guide for the broader header-stripping pattern).
  • Report-only mode left on forever. See the callout above.
  • Allowing data: in script-src. Inline data URIs can carry JavaScript. Only allow data: in img-src if you actually use inline images.

Knowledge check

Test your understanding of Content-Security-Policy with these questions. Click or tap each card to flip it and see the answer.

Q1Your school's CSP is default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'. A cross-site scripting bug is later discovered in a contact form plugin. What does the CSP actually stop here?tap to flip
Answer: For the XSS case, almost nothing. The script-src directive includes 'unsafe-inline', which allows the browser to execute any inline <script> tag regardless of source. The attacker's injected script is inline, so it runs. The rest of the policy still helps (it would still block a script loaded from a third-party domain, an inline event handler used as a script-injection vector in some browsers, and any other resources), but for the most common XSS pattern the CSP is doing nothing. Remove unsafe-inline from script-src and use a nonce or hashes instead (3).
Q2You add a nonce-based CSP: script-src 'self' 'nonce-RandomValue=='. The browser blocks every legitimate inline script on the page. What is the most likely cause?tap to flip
Answer: The nonce on the inline <script> tag does not match the nonce in the policy. The two most common causes are: (1) the nonce is generated per request but the HTML is cached by a CDN or reverse proxy and served stale, so the HTML's nonce is from a previous request and does not match the response header; (2) the server template generates the nonce in the HTML but the CSP header is generated by a different component (nginx add_header, a middleware, a CDN) using a different random source. The two paths must agree on the same nonce value (5).
Q3You switch your CSP to report-only mode for two weeks. After week one, the report log is empty. Is the policy ready to switch to enforcement mode?tap to flip
Answer: Almost certainly not. An empty report log usually means one of three things: (1) the report-uri endpoint is broken (returns 404 or 500, browser gives up); (2) no traffic is reaching the page during the test window (try in production with real visitors); (3) the browser is enforcing the policy already as report-only and the page is fully compliant. To diagnose, hit the page yourself with dev tools' CSP blocked resources loaded and confirm reports show up, or temporarily set a deliberately bad directive (like script-src 'none') and check that the report for that fires. Only switch to enforcement once you have confirmed the reporting pipeline is alive and the volume of legitimate-resource violations is zero or accounted for (1).

Sources and references

[1] Mozilla. (2025). MDN Web Docs — Content Security Policy (CSP). https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

[2] Google. (2025). Web Fundamentals — Content Security Policy. https://developers.google.com/web/fundamentals/security/csp

[3] OWASP Foundation. (2025). Cross-Site Scripting (XSS) Prevention Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html

[4] OWASP Foundation. (2025). Clickjacking Defense Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html

[5] Internet Engineering Task Force. (2025). CSP Level 3 — Nonces and hashes. https://www.w3.org/TR/CSP3/

[6] CERT NZ. (2025). Top 11 cyber security tips for your business. https://www.cert.govt.nz/business/guides/top-tips-for-cyber-security/

[7] Mozilla. (2025). Web security guidelines. https://infosec.mozilla.org/guidelines/web_security

What to do next