Mastering Content Security Policy (CSP) with Nginx: A Deep Dive
A comprehensive guide to implementing a strict Content Security Policy (CSP) on Nginx for both static and dynamic sites, covering nonces, hashes, and security best practices.
In the modern web, security cannot be an afterthought. Cross-Site Scripting (XSS) remains one of the most prevalent vulnerabilities, allow and execute. Content Security Policy (CSP) is the most effective countermeasure against these attacks, acting as a whitelist that tells the browser exactly which resources are allowed to load and execute.
However, implementing a strict CSP—especially on static sites generated by tools like Astro, Next.js, Jekyll, or Hugo—can be challenging. How do you use cryptographic nonces when your HTML is pre-built?
In this deep dive, I will explain how to implement an A+ rated CSP using Nginx, specifically focusing on the advanced techniques used on this very website (jmrp.io).
Why Do We Need CSP? The Threat Landscape
Before diving into the code, it’s crucial to understand what we are protecting against. CSP is not just a “nice to have”; it is a critical defense layer against several specific types of attacks.
1. Cross-Site Scripting (XSS)
What is it? XSS occurs when an attacker injects malicious code (usually JavaScript) into a legitimate website.
- The Risk: If an attacker can execute their script in your user’s browser, they can steal session cookies (account hijacking), redirect the user to a phishing site, or perform actions on the user’s behalf.
- How CSP Helps: By defining a
script-srcwhitelist, CSP tells the browser: “Only execute scripts from this domain or scripts that have this specific nonce.” Even if an attacker injects<script>alert('Hacked')</script>, the browser will refuse to run it because it lacks the valid nonce.
2. Data Injection & Exfiltration
What is it? Attackers might try to load external resources (like tracking pixels) or send sensitive data (like keystrokes) to their own servers.
- The Risk: Unsolicited tracking or theft of private user data entered into forms.
- How CSP Helps: Directives like
connect-srcandimg-srclimit where the browser can send data. If an attacker tries to send your credit card number tomalicious-site.com, CSP blocks the connection.
3. Clickjacking (UI Redressing)
What is it? An attacker embeds your website inside an invisible <iframe> on their own site. They then trick users into clicking buttons on your site (like “Delete Account” or “Transfer Money”) thinking they are clicking something else.
- How CSP Helps: The
frame-ancestorsdirective effectively replaces the olderX-Frame-Optionsheader. Setting it to'none'ensures your site cannot be embedded anywhere, completely neutralizing this attack.
4. Packet Sniffing (Mixed Content)
What is it? Loading insecure (HTTP) resources on a secure (HTTPS) page.
- The Risk: An attacker on the same network (like a public Wi-Fi) can see or modify the insecure content.
- How CSP Helps: The
upgrade-insecure-requestsdirective forces the browser to try and load all resources over HTTPS, even if the code specifically asks for HTTP.
The Challenge: Static Content vs. Dynamic Security
A robust CSP relies on nonces (numbers used once)—random, unpredictable tokens generated for every single request.
- The server generates a token:
nonce="abc123...". - The server sends this token in the
Content-Security-Policyheader. - The server puts the same token on
<script>tags:<script nonce="abc123...">. - The browser executes the script only if the header and tag match.
The Problem: Static Site Generators (SSG) build HTML files once at build time. They cannot predict the random nonce that Nginx will generate weeks later when a user visits the site.
The Solution: Nginx Sub-Filter & Placeholders
The elegant solution used on jmrp.io involves a partnership between the static build and the web server.
1. The Placeholder
In our source code (Astro, React, plain HTML), we don’t use a random number. We use a known, static string as a placeholder. I use NGINX_NONCE_CSP.
<script is:inline nonce="NGINX_NONCE_CSP">
// Your critical inline script console.log("Hello from a secure inline
script!");
</script> 2. The Injection (Nginx)
We configure Nginx to generate a request ID and search-and-replace our placeholder with this ID before sending the response to the user.
server {
# 1. Use the unique Request ID as our CSP Nonce
# This ID is unique per request and random enough for CSP
set $cspNonce $request_id;
# 2. Configure the sub_filter module
# This replaces the string 'NGINX_NONCE_CSP' with the actual variable content
# Note: Requires ngx_http_sub_module (usually included in full/extras builds)
sub_filter_once off; # Replace all occurrences
sub_filter_types *; # Apply to all content types (JS, HTML, etc.)
sub_filter NGINX_NONCE_CSP $cspNonce;
# 3. Add the CSP Header
# We reference the same variable ($cspNonce) in the header
add_header Content-Security-Policy "script-src 'self' 'nonce-$cspNonce' ..." always;
}
Why this works: The browser receives a valid HTML file with a matching nonce in the header and the body, completely unaware that Nginx modified the HTML on the fly. This allows us to have the performance of a static site with the security of a dynamic one.
The Transformation
Here is what happens “on the wire” when Nginx processes a request:
<!-- What is stored in /var/www/... -->
<script nonce="NGINX_NONCE_CSP">
console.log('Static Placeholder');
</script> <!-- What the user actually receives -->
<script nonce="a1b2c3d4e5f6g7h8i9j0...">
console.log('Static Placeholder');
</script> Anatomy of a Strict CSP
Let’s break down the directives used in a production-ready policy. A weak CSP provides a false sense of security, so understanding these is crucial.
1. The Safety Net: default-src
default-src ‘none’;
Recommendation: Always start with 'none'. This ensures that any directive you forget to configure defaults to “block” rather than “allow”. It forces you to be explicit about what you trust.
2. The Danger Zone: script-src
This is where XSS is prevented or allowed.
'self': Allows scripts from your own domain.'nonce-$cspNonce': Allows inline scripts with the correct token.'strict-dynamic'(Advanced): If a trusted script loads another script, trust that one too.'unsafe-inline'(AVOID): Allows all inline scripts. This effectively disables XSS protection.'unsafe-eval': Allowseval(). Avoid unless absolutely necessary (some older libraries require it).
<!-- Blocked by CSP -->
<script>
alert('XSS Attack!');
</script>
<!-- Also Blocked (Event Handlers) -->
<button onclick="doSomething()">Click Me</button><!-- Allowed (Matches Header) -->
<script nonce="NGINX_NONCE_CSP">
document.getElementById('btn')
.addEventListener('click', doSomething);
</script>3. Styling: style-src
Styles are generally less dangerous than scripts, but can still be used for phishing (overlaying content) or data exfiltration.
'self': Your own CSS files.'unsafe-inline': Often required for JS frameworks that inject styles dynamically.'unsafe-hashes': A middle ground. Allows specific inline styles (identified by their SHA-256 hash) without opening the floodgates.
4. External Integrations & Connections
Modern sites rarely live in isolation. You likely use Google Analytics, Fonts, or CDNs.
connect-src: Controls where your app can send data (AJAX, WebSockets,fetch).- Example:
connect-src 'self' https://api.github.com;
- Example:
img-src: Controls image sources.- Example:
img-src 'self' data: https://res.cloudinary.com;
- Example:
form-action: Critical for anti-phishing. Controls where forms can submit data.- Recommendation:
form-action 'self';(Only allow forms to post to your own server).
- Recommendation:
frame-ancestors: Critical for anti-clickjacking. Controls who can embed your site in an iframe.- Recommendation:
frame-ancestors 'none';(Nobody can embed me).
- Recommendation:
Common Pitfalls & “Bad” Directives
Not all CSP directives increase security. Some are escape hatches that should be used sparingly.
# Allows any inline script
# Allows loading scripts from ANY HTTPS URL
script-src 'self' 'unsafe-inline' https:;# Only allows specific, trusted scripts
# Rejects everything else
script-src 'self' 'nonce-$cspNonce';SHA Hashes: Trusting the Immutable
Sometimes you can’t add a nonce to a script (e.g., a third-party library that inserts a specific inline script). In this case, you can whitelist the hash of the script’s content.
If Nginx blocks a script, the browser console will often tell you the required hash:
Refused to execute inline script because it violates the following Content Security Policy directive… The hash of the script is
'sha256-AbCdEf...'.
You can then add 'sha256-AbCdEf...' to your script-src.
Validation & Testing
A broken CSP breaks your site. Never deploy a strict policy to production without testing.
- Report-Only Mode: Use
Content-Security-Policy-Report-Onlyheader first. Browsers will report violations to the console (or areport-uri) but won’t block anything. - Mozilla Observatory: The gold standard for grading your headers.
- Google CSP Evaluator: Helps find logical holes in your policy (like trusting widely compromised CDNs).
My Results
After tuning the policy for jmrp.io, removing unsafe directives, and implementing the Nginx nonce injection:
This setup proves that you don’t need to sacrifice security for the convenience of a static site generator. With a few lines of Nginx configuration, you can achieve top-tier protection.