CSP: Content Security Policy Deep Dive

Content Security Policy is arguably the most important HTTP security header for modern web applications. It provides a declarative way to tell the browser exactly which resources are allowed to load and execute on your page. A well-configured CSP is the strongest defense against cross-site scripting (XSS), data injection attacks, and many forms of content hijacking. However, CSP is also one of the most commonly misconfigured headers — a single mistake can either break your site or leave it unprotected. This lesson provides a thorough walkthrough of every CSP concept you need to master.

How CSP works

When a browser receives a Content-Security-Policy header (or a <meta> tag equivalent), it parses the policy into a set of directives. Each directive specifies which sources are allowed for a particular type of resource. When the browser encounters a resource — a script tag, an image, a stylesheet, a font, an iframe — it checks the CSP policy to see if that resource's origin is permitted. If it is not, the browser blocks the resource and logs a violation to the console.

CSP directives

Each directive controls a specific type of resource. Here are the most important ones:

default-src

The fallback directive. If a more specific directive is not defined, the browser uses default-src. Setting default-src 'none' and then explicitly allowing each resource type is the most secure approach.

default-src 'none';

script-src

Controls which JavaScript sources can execute. This is the most critical directive for preventing XSS. Common values include 'self' (same-origin scripts), specific domains, nonces, and hashes.

script-src 'self' 'nonce-abc123' https://cdn.example.com;

style-src

Controls which CSS sources can be applied. Similar to script-src, you can use 'self', specific domains, nonces, and hashes.

style-src 'self' 'nonce-xyz789' https://fonts.googleapis.com;

img-src

Controls which image sources are allowed. The data: scheme is often needed for inline images (base64-encoded). The blob: scheme may be needed for dynamically generated images.

img-src 'self' data: https:;

connect-src

Controls which URLs the page can connect to via fetch(), XMLHttpRequest, WebSocket, EventSource, and the Beacon API. This is crucial for controlling where your application sends data.

connect-src 'self' https://api.example.com wss://ws.example.com;

font-src

Controls which sources can serve fonts. If you use Google Fonts or a self-hosted font CDN, you need to allow those origins here.

font-src 'self' https://fonts.gstatic.com;

frame-src

Controls which sources can be loaded in <iframe>, <frame>, <object>, and <embed> elements. If your page does not embed external content, set this to 'none'.

frame-src 'self' https://www.youtube.com;

frame-ancestors

Controls which pages are allowed to embed your page in a frame. This is the CSP replacement for X-Frame-Options. Setting it to 'none' prevents clickjacking entirely.

frame-ancestors 'none';

object-src

Controls sources for <object>, <embed>, and <applet> elements. These elements are rarely needed in modern applications and should almost always be set to 'none'.

object-src 'none';

base-uri

Restricts the URLs that can be used in a <base> element. An attacker who can inject a <base> tag can redirect all relative URLs on your page to a malicious server. Always set this to 'self' or 'none'.

base-uri 'self';

Nonces vs hashes vs strict-dynamic

One of the most important decisions when configuring CSP is how to allow your own inline scripts and styles. There are three main approaches, each with trade-offs.

Nonces

A nonce (number used once) is a random value generated by the server on every page load. The server adds the nonce to both the CSP header and the nonce attribute of each allowed script or style tag. The browser only executes scripts and styles whose nonce attribute matches the one in the CSP header.

# Server generates a random nonce
$nonce = base64_encode(random_bytes(16));

# CSP header includes the nonce
Content-Security-Policy: script-src 'nonce-YWJjMTIz' 'strict-dynamic';

# HTML script tags include the matching nonce
<script nonce="YWJjMTIz">console.log('This runs');</script>

Advantages of nonces

  • Works with dynamically generated inline scripts.
  • No need to compute or maintain hash values.
  • Simple to implement in server-side rendered applications.

Requirements

  • Must be cryptographically random (at least 128 bits of entropy).
  • Must be unique for every page load — reusing nonces defeats the purpose.
  • Requires server-side rendering to inject the nonce into both the header and HTML.

Hashes

Instead of a nonce, you can specify the exact SHA-256 (or SHA-384, SHA-512) hash of allowed inline scripts. The browser computes the hash of each inline script it encounters and only executes those that match a hash in the CSP header.

Content-Security-Policy: script-src 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=';

Advantages of hashes

  • Works with static sites and CDNs where server-side rendering is not available.
  • Does not require generating and injecting a nonce on every request.

Disadvantages

  • Any change to the script content (even whitespace) changes the hash, requiring a CSP update.
  • Difficult to manage when scripts change frequently.
  • Does not work for dynamically generated scripts.

strict-dynamic

The 'strict-dynamic' keyword tells the browser: "Trust any script that is loaded by a script I already trust." When you combine 'strict-dynamic' with a nonce, you only need to add the nonce to your top-level script tags. Any scripts those scripts load dynamically (e.g., via document.createElement('script')) are automatically trusted.

Content-Security-Policy: script-src 'nonce-YWJjMTIz' 'strict-dynamic';

This is the recommended approach for modern applications because it is both secure and practical. It eliminates the need to maintain an allowlist of CDN domains, which can be bypassed via script gadgets.

Report-uri and report-to for monitoring violations

CSP provides two mechanisms for reporting policy violations, allowing you to monitor for attacks and identify misconfigurations.

report-uri (deprecated but still widely used)

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

When a violation occurs, the browser sends a JSON POST request to the specified URI containing details about the blocked resource, the violated directive, and the page where it occurred.

report-to (modern replacement)

Report-To: {"group":"csp","max_age":86400,"endpoints":[{"url":"https://example.com/csp-reports"}]}
Content-Security-Policy: default-src 'self'; report-to csp;

The report-to directive uses the Reporting API, which batches and deduplicates reports for better performance.

Report-Only mode

Before enforcing a CSP, use the Content-Security-Policy-Report-Only header to run the policy in monitoring mode. Violations are reported but not blocked. This lets you identify everything that would break before you enforce the policy.

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

Common pitfalls

unsafe-inline

Adding 'unsafe-inline' to your CSP is the most common mistake. It tells the browser to allow all inline scripts and styles, which completely negates CSP's protection against XSS. If an attacker can inject <script>malicious code</script> into your page, 'unsafe-inline' lets it run.

# BAD - defeats the purpose of CSP
script-src 'self' 'unsafe-inline';

# GOOD - use nonces instead
script-src 'self' 'nonce-abc123';

unsafe-eval

'unsafe-eval' allows the use of eval(), new Function(), setTimeout('string'), and other string-to-code APIs. These are common vectors for XSS attacks. Some libraries require eval (e.g., older template engines), but modern alternatives exist for nearly all use cases.

Overly broad allowlists

Allowing entire CDN domains like https://cdn.jsdelivr.net or https://cdnjs.cloudflare.com in script-src is dangerous. Attackers can host malicious scripts on these same CDNs and bypass your CSP. Use nonces or hashes with 'strict-dynamic' instead of domain allowlists for scripts.

Forgetting about inline event handlers

Inline event handlers like onclick="doSomething()" are blocked by CSP even with nonces. You cannot add a nonce to an inline event handler. The solution is to use addEventListener() in a nonced script block instead.

# BAD - blocked by CSP
<button onclick="doSomething()">Click</button>

# GOOD - use addEventListener in a nonced script
<button id="myBtn">Click</button>
<script nonce="abc123">
  document.getElementById('myBtn').addEventListener('click', doSomething);
</script>

Step-by-step guide to implementing CSP

  1. Audit your resources: Open your browser's developer tools (Network tab) and catalog every resource your page loads — scripts, styles, images, fonts, API calls, iframes, and more. Note their origins.
  2. Start with report-only: Deploy a strict CSP in report-only mode. Set default-src 'none' and add directives for each resource type based on your audit.
  3. Monitor violations: Collect and analyze violation reports for a few days. Each report tells you what was blocked and why. Adjust your policy to allow legitimate resources.
  4. Replace inline scripts with nonces: Add nonce generation to your server. Assign nonces to all inline <script> and <style> tags. Remove all onclick, onload, and other inline event handlers.
  5. Add strict-dynamic: Include 'strict-dynamic' in script-src so that scripts loaded by your nonced scripts are automatically trusted.
  6. Test thoroughly: Verify all functionality works with the CSP enforced. Test in multiple browsers. Check for console errors related to CSP violations.
  7. Enforce the policy: Switch from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep report-to active so you can continue monitoring.
  8. Iterate: CSP is not set-and-forget. When you add new features, third-party integrations, or analytics tools, update your CSP accordingly.

How the CodeFrog website uses CSP with nonces

The very page you are reading right now uses CSP with nonces. Here is how it works:

  1. When your browser requests this page, the PHP server generates a unique cryptographic nonce using generate_script_nonce().
  2. The nonce is included in the Content-Security-Policy header sent with the response: script-src 'nonce-...' 'strict-dynamic'.
  3. Every <script> tag on the page includes the matching nonce attribute: <script nonce="...">.
  4. When your browser parses the page, it checks each script's nonce against the CSP header. Only scripts with the correct nonce execute.
  5. If an attacker managed to inject a script tag, it would not have the correct nonce and would be blocked by the browser.

This is a practical, real-world implementation of CSP with nonces. Every page on codefrog.app generates a fresh nonce on every request, ensuring that even if an attacker could predict the nonce from one request, it would not be valid for the next.

Tip: When CodeFrog scans other websites as part of its mega report, it checks whether those sites have a Content-Security-Policy header and evaluates its strength. Sites without CSP or with weak CSP (e.g., 'unsafe-inline') are flagged as security findings.

Resources