Content Security Policy From Scratch: Your First CSP Without Breaking Your Site

Profile
Yves Soete Follow

Mar 24, 2026 · 8 min read

Content Security Policy is the single most effective defense against cross-site scripting. A well-configured CSP tells the browser exactly which resources it is allowed to load and from where — scripts, stylesheets, images, fonts, frames, connections. Anything not explicitly permitted gets blocked. If an attacker manages to inject a script tag into your page, CSP prevents the browser from executing it because the source is not in your allowlist.

The problem is that CSP is notorious for breaking sites during deployment. Teams add the header, their analytics stops working, their third-party chat widget disappears, their inline styles break, and they rip the whole thing out within an hour. This guide walks through the rollout process that avoids that outcome: audit first, deploy in report-only mode, analyze violations, then enforce.

The Directive Model: What Each Directive Controls

CSP works through directives, each controlling a specific resource type. The browser evaluates every resource load against the matching directive and blocks anything that does not match. If no specific directive exists for a resource type, the browser falls back to default-src.

Directive Controls Example sources
default-src Fallback for all resource types not explicitly listed 'self'
script-src JavaScript files and inline scripts 'self' 'nonce-abc123'
style-src CSS files and inline styles 'self' 'unsafe-inline'
img-src Images (including favicons and SVGs via img tags) 'self' data: https:
connect-src XHR, Fetch, WebSocket, EventSource connections 'self' https://api.example.com
font-src Web fonts loaded via @font-face 'self' https://fonts.gstatic.com
frame-src Sources allowed to be embedded in iframes https://www.youtube.com
frame-ancestors Who can embed your page in an iframe (replaces X-Frame-Options) 'none'

The key mental model: default-src is your baseline, and every other directive overrides it for that specific resource type. Setting default-src 'self' and then img-src 'self' data: https: means images have a broader policy than everything else. Directives do not inherit from default-src and add to it — they replace it entirely for that type.

Starting With Report-Only Mode

The single most important step in CSP deployment is not deploying it as Content-Security-Policy. Deploy it as Content-Security-Policy-Report-Only first. This header tells the browser to evaluate the policy and report violations — but not block anything. Your site continues to work exactly as before while you collect data on what would break.

The report-only header supports a report-uri directive (deprecated but still widely supported) or a report-to directive (the modern approach using the Reporting API). Violation reports are JSON payloads the browser sends to your designated endpoint:

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

Each violation report includes the violated directive, the blocked URI, the document URI where the violation occurred, and the source file and line number if available. After a week of collecting reports from real traffic, you have a complete picture of every resource your site loads that would be blocked by the policy.

Services like report-uri.com and Sentry can aggregate CSP violation reports and provide dashboards showing the most common violations, making it straightforward to identify which sources need to be added to your policy before switching from report-only to enforced.

Nonces vs Hashes for Inline Scripts

The moment you need inline JavaScript — an analytics snippet, a configuration object, an initialization script — you hit CSP's most practical challenge. The script-src 'self' directive blocks all inline scripts by default. You have three options, in order of preference:

Option 1: Nonce-based approach (recommended)

Generate a cryptographically random nonce (at least 128 bits, base64-encoded) on each page load. Add it to both the CSP header and the script tag:

Content-Security-Policy: script-src 'nonce-4a8f2c1e9b'

<script nonce="4a8f2c1e9b">
  window.config = { apiUrl: '/api/v1' };
</script>

The nonce must be unique per response. If you use the same nonce across requests, an attacker who observes one response can inject scripts with that nonce. In server-side frameworks, generating a per-request nonce is straightforward — Phoenix, Rails, Django, and Express all have middleware or plugs for this. In Phoenix specifically, you can generate a nonce in your :browser pipeline and pass it through assigns.

Option 2: Hash-based approach

Instead of a nonce, you hash the exact content of the inline script and include the hash in the CSP header:

Content-Security-Policy:
  script-src 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='

The hash must match the exact content of the script block, including whitespace. If the script content changes (even adding a space), the hash breaks. This works well for truly static inline scripts but becomes fragile for any content that varies per request or per environment.

Option 3 (avoid): 'unsafe-inline'

Adding 'unsafe-inline' to script-src allows all inline scripts, which negates most of the XSS protection CSP provides. If an attacker can inject HTML, they can inject an inline script and it will execute. This is the option most teams reach for when CSP "breaks things" — and it is the option that makes CSP nearly useless for script protection. Use nonces instead.

Note: 'unsafe-inline' for style-src is more acceptable because inline styles are a much weaker attack vector than inline scripts. Many CSS frameworks and component libraries set inline styles, and the XSS risk from CSS is minimal compared to JavaScript. If you need it for styles, use it — but not for scripts.

Common Mistakes That Undermine Your CSP

These are the patterns that make a CSP look correct in the header but provide little or no actual security:

  1. Using 'unsafe-eval' in script-src: This allows eval(), new Function(), and setTimeout('string'). Some older libraries require it (AngularJS 1.x, certain template engines), but it opens a significant attack surface. Modern frameworks do not need it.
  2. Overly broad wildcards: script-src https: allows scripts from any HTTPS origin, including attacker-controlled domains. script-src *.googleapis.com is better but still covers hundreds of Google services including JSONP endpoints that can be used for CSP bypasses. Be as specific as possible — https://www.googletagmanager.com is better than *.google.com.
  3. Missing base-uri directive: Without base-uri 'self', an attacker who can inject a <base href="https://evil.com"> tag can redirect all relative URL loads to their server. This is a commonly overlooked bypass.
  4. Missing form-action directive: Even with a strict script-src, an attacker can inject a form that posts credentials to their server if form-action is not restricted.
  5. Allowing data: in script-src: The data: scheme allows inline script execution via data URIs. It belongs in img-src (for base64-encoded images) and font-src (for embedded fonts), but never in script-src.
  6. JSONP endpoints on whitelisted origins: If you whitelist https://accounts.google.com in script-src and that origin has a JSONP endpoint, an attacker can use it to execute arbitrary JavaScript within your CSP. Google has published a list of CSP-bypass-capable endpoints on their own domains. The nonce-based approach sidesteps this entirely.

Step-by-Step Rollout Process

Here is the concrete process for deploying CSP on an existing production site without downtime or broken functionality:

Step 1: Audit existing resources. Open your site in Chrome DevTools, go to the Network tab, and reload. Every request you see is a resource that your CSP must permit. Group them by type: scripts, stylesheets, images, fonts, XHR/Fetch calls, WebSocket connections, iframes. Note the origin of each. Tools like Kuality's header scanner can automate this audit across multiple pages.

Step 2: Build the initial policy. Start restrictive and add what you found:

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

Step 3: Deploy as report-only. Set the Content-Security-Policy-Report-Only header with a report-uri pointing to your violation collection endpoint. Leave it running for at least one week to capture traffic across all pages, user flows, and edge cases you may not have tested manually.

Step 4: Analyze violation reports. Group violations by directive and blocked URI. Most violations will fall into a few categories: third-party scripts you missed in the audit, inline scripts that need nonces, and browser extensions generating false positives. Filter out extension-related violations (they typically reference chrome-extension:// or moz-extension:// URIs) — you do not need to whitelist those.

Step 5: Iterate the policy. Add legitimate sources that appeared in violation reports. For each addition, ask: is this a resource my site intentionally loads? If yes, add the specific origin. If no, investigate — it might be a tracking pixel injected by a browser extension, or it might be an actual security concern.

Step 6: Switch to enforced mode. Replace Content-Security-Policy-Report-Only with Content-Security-Policy. Keep the report-uri directive so you continue to receive violation reports — new third-party integrations, updated SDKs, or marketing team changes can introduce new sources that need to be added.

Google Tag Manager and CSP: The Hardest Integration

Google Tag Manager is the most difficult third-party service to integrate with a strict CSP because its entire purpose is to dynamically inject arbitrary scripts into your page — exactly what CSP is designed to prevent.

GTM itself loads from https://www.googletagmanager.com, but the tags it injects can load scripts from any origin: Google Analytics, Facebook Pixel, Hotjar, LinkedIn Insight, and whatever else your marketing team configures. Each of those scripts may load additional scripts from their own CDNs. Your CSP has to allow all of them.

The practical approaches:

  • Nonce propagation (best option): GTM supports a feature called custom template sandboxing and nonce injection. You pass the page's CSP nonce to the GTM container, and GTM applies it to the scripts it injects. This requires using GTM's custom templates rather than custom HTML tags, which limits flexibility but maintains CSP integrity.
  • Explicit origin allowlisting (common compromise): Audit every tag in your GTM container. For each one, identify the script origins it loads and add them to your CSP. This works but creates a maintenance burden — every time marketing adds a new tag, the CSP needs updating. Document the process and make it part of the GTM change workflow.
  • Server-side GTM (most secure): Run a server-side GTM container that proxies all tag requests through your own domain. From the browser's perspective, all scripts load from 'self'. This eliminates the CSP challenge entirely but requires running GTM's server-side container (Google Cloud Run is the standard deployment target) and reconfiguring all tags for server-side execution.

The worst option, which is unfortunately common, is adding 'unsafe-inline' 'unsafe-eval' to script-src to make GTM work. This defeats the primary purpose of CSP. If you find yourself doing this, server-side GTM is worth the infrastructure investment.

Real-World CSP Examples by Stack

Here are production-ready CSP policies for common application architectures. Each assumes you have done the audit step and adjusted origins to match your actual integrations.

Static marketing site with Google Analytics and Google Fonts:

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

SPA with API backend (React, Vue, or similar):

default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' https://api.yourapp.com
  wss://api.yourapp.com;
worker-src 'self' blob:;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;

Phoenix LiveView application:

default-src 'self';
script-src 'self' 'nonce-GENERATED_VALUE';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self' wss://yourapp.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';

The LiveView example includes wss: in connect-src because LiveView maintains a WebSocket connection for real-time updates. Missing this directive will cause the LiveSocket to fail silently, which manifests as live navigation and form submissions not working — a symptom that is not immediately obvious as a CSP issue.

CSP is not a set-and-forget header. Your policy needs to evolve as your site changes — new integrations, updated SDKs, and architectural changes all require policy updates. Automated scanning catches drift between your intended policy and what your headers actually specify, flagging misconfigurations before they become vulnerabilities or before an overly restrictive policy breaks functionality in production.

Scan your HTTP headers including CSP configuration →
Version 1.0.0