Content Security Policy: The One Header That Stops XSS and What Developers Keep Getting Wrong

Content Security Policy is the most effective browser-enforced defence against XSS. This guide covers how to build a working CSP from scratch, avoid the nonce and hash pitfalls, and actually verify your policy does what you think it does.

Content Security Policy (CSP) is the browser’s native XSS defence: a HTTP response header that tells the browser exactly which resources it is allowed to load and execute. When implemented correctly, a strict CSP makes reflected and stored XSS substantially harder to exploit — an injected <script> tag simply will not execute because the browser rejects it before the JavaScript engine sees it.

Most production CSPs are not implemented correctly. The two most common failures are: (1) the policy is so permissive that it provides no meaningful protection, and (2) the policy is in report-only mode and was never promoted to enforcement. This guide covers how to do it right.

How CSP Works

When a browser receives a response with a Content-Security-Policy header, it applies the directives as policy for every resource the page attempts to load or execute. Each directive controls a specific resource type:

  • script-src — JavaScript execution sources
  • style-src — CSS sources
  • img-src — image sources
  • connect-src — fetch, XHR, WebSocket connections
  • frame-src — iframe sources
  • object-src — plugin objects (Flash, Java applets — should always be 'none')
  • base-uri — allowed values for <base href> (should always be 'self')
  • form-action — where forms can submit to

If a resource does not match an allowed source, the browser blocks it and optionally reports the violation.

The Difference Between a Real CSP and a Checkbox CSP

A permissive policy that includes 'unsafe-inline' in script-src provides essentially no XSS protection. Any injected inline <script> tag will execute because 'unsafe-inline' explicitly permits it. A large proportion of CSPs in production use 'unsafe-inline' — often because developers added it to stop their own legitimate inline scripts from breaking, rather than refactoring to nonces or external files.

The following policy is real but useless:

Content-Security-Policy: script-src 'self' 'unsafe-inline' https://cdn.example.com; style-src 'self' 'unsafe-inline'

A meaningful strict CSP looks like this:

Content-Security-Policy: 
  default-src 'none';
  script-src 'nonce-{random}' 'strict-dynamic';
  style-src 'nonce-{random}';
  img-src 'self' data:;
  connect-src 'self' https://api.example.com;
  font-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;

Nonce-Based CSP: The Correct Approach

A nonce is a cryptographically random value generated fresh for every HTTP response. It is included in the CSP header and as an attribute on every <script> and <style> tag the server renders legitimately. The browser only executes scripts that carry the correct nonce value.

Because the nonce changes with every response, an attacker who injects <script>alert(1)</script> into the page cannot know the current nonce value and cannot make the browser execute the injected script.

Generating a nonce in Node.js:

const crypto = require('crypto');

function generateNonce() {
  return crypto.randomBytes(16).toString('base64');
}

// Express middleware
app.use((req, res, next) => {
  res.locals.nonce = generateNonce();
  res.setHeader(
    'Content-Security-Policy',
    `default-src 'none'; ` +
    `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic'; ` +
    `style-src 'nonce-${res.locals.nonce}'; ` +
    `img-src 'self' data:; ` +
    `connect-src 'self'; ` +
    `object-src 'none'; ` +
    `base-uri 'self'; ` +
    `form-action 'self'; ` +
    `frame-ancestors 'none';`
  );
  next();
});

Using the nonce in templates (Handlebars):

<script nonce="{{nonce}}">
  // Your legitimate inline JavaScript
  const config = { apiUrl: '{{apiUrl}}' };
</script>

<script nonce="{{nonce}}" src="/bundle.js"></script>

Generating a nonce in Python (Django):

import secrets
import base64

class CSPMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        nonce = base64.b64encode(secrets.token_bytes(16)).decode('utf-8')
        request.csp_nonce = nonce
        response = self.get_response(request)
        csp = (
            f"default-src 'none'; "
            f"script-src 'nonce-{nonce}' 'strict-dynamic'; "
            f"style-src 'nonce-{nonce}'; "
            f"img-src 'self' data:; "
            f"connect-src 'self'; "
            f"object-src 'none'; "
            f"base-uri 'self'; "
            f"form-action 'self'; "
            f"frame-ancestors 'none';"
        )
        response['Content-Security-Policy'] = csp
        return response

Hash-Based CSP: When You Have Static Inline Scripts

If you have small, static inline scripts that do not change, you can use SHA-256 hashes instead of nonces. The CSP header lists the hash of the exact permitted script content; the browser executes it only if the content matches the hash.

import hashlib
import base64

script_content = "const x = 1;"
digest = hashlib.sha256(script_content.encode('utf-8')).digest()
hash_value = base64.b64encode(digest).decode('utf-8')
# hash_value: 'sha256-<base64>'

# Use in CSP:
# script-src 'sha256-<hash_value>'

This is harder to maintain than nonces — every change to the script requires updating the CSP header — and is unsuitable for dynamically generated content. Use nonces instead unless you have a specific reason not to.

strict-dynamic: The Critical Addition

Adding 'strict-dynamic' to script-src allows nonce-carrying scripts to dynamically load further scripts. Without it, a SPA that loads its bundle via a nonce-bearing <script> tag, and then uses that bundle to dynamically inject additional <script> elements, would have those injected scripts blocked by the CSP.

'strict-dynamic' propagates trust from the nonce-bearing root script to any scripts it creates. Importantly, it also ignores allowlisted hosts — the presence of 'strict-dynamic' means the host allowlist in script-src is superseded for dynamically loaded scripts. This is almost always what you want.

The CSP Pitfalls That Break Protection

unsafe-eval — permits eval(), Function(), setTimeout(string), and setInterval(string). Many JavaScript bundlers and templating engines use eval-style execution. Avoid it where possible; it defeats a significant portion of CSP’s value. If a third-party library requires unsafe-eval, consider whether it belongs in a security-sensitive context.

data: URIs in script-srcdata: URIs can carry arbitrary base64-encoded JavaScript. Never include data: in script-src.

Overly broad host wildcardsscript-src *.googleapis.com permits loading scripts from any Google-hosted origin, including those that serve user-controlled content. An attacker who can host content at any subdomain of a whitelisted domain can bypass the CSP. Use exact origins, not wildcards.

Not setting object-src 'none' — Flash objects and Java applets can execute script. Always set object-src 'none' explicitly, even if you believe you have no plugin usage.

Not setting base-uri 'self' — An injected <base href="https://attacker.com"> tag can redirect all relative URLs in the document. The base-uri directive prevents this.

Report-Only Mode for Staged Rollout

Use Content-Security-Policy-Report-Only with a reporting endpoint to observe violations before enforcement:

Content-Security-Policy-Report-Only: 
  default-src 'none';
  script-src 'nonce-{random}' 'strict-dynamic';
  report-uri https://your-domain.report-uri.com/r/d/csp/reportOnly;

Third-party services like Report URI or self-hosted implementations using the reporting API make it straightforward to aggregate and triage violations. Run in report-only for at least one full week — covering all user flows including edge cases — before switching to enforcement.

After switching to enforcement, keep the reporting endpoint active. Real violations in production are valuable signals that either indicate broken functionality that needs a CSP exception or, more interestingly, active XSS exploitation attempts.

Verifying Your CSP Is Actually Protective

The Google CSP Evaluator (csp-evaluator.withgoogle.com) analyses a CSP string and identifies bypass-enabling patterns. Run your policy through it before enforcement — the common failure modes (unsafe-inline, missing object-src, data: URI in script-src) are all flagged.

For a more comprehensive test, the CSP Tester extension in Chrome’s DevTools will report violations in the browser console during manual QA. If your legitimate application flow generates CSP violation reports, those need to be resolved before enabling enforcement mode.

A production policy that is enforced, generates no false-positive violations, blocks unsafe-inline, and uses per-request nonces represents a meaningfully raised bar against XSS exploitation.