How to Implement Modern Content Security Policy (CSP) Directives Without Inline Scripts: A Beginner's Technical Guide
Cross-Site Scripting (XSS) remains one of the most pervasive vulnerabilities in web development. An attacker who successfully executes arbitrary JavaScript in a victim's browser can hijack sessions, steal sensitive tokens, or manipulate page content.
A Content Security Policy (CSP) acts as an essential defense-in-depth layer. It is an HTTP response header that tells the browser which sources of content—such as JavaScript, CSS, and images—are trustworthy.
Historically, legacy CSP implementations relied on domain allowlists. However, research has repeatedly shown that domain allowlists are easily bypassed through open redirects, JSONP endpoints, or hosted libraries. Modern CSP standards address these weaknesses by abandoning domain allowlists and eliminating inline scripts entirely.
Here is a step-by-step guide to configuring a strict, modern CSP that completely blocks untrusted inline execution.
The Danger of Inline Scripts and 'unsafe-inline'
An inline script is any JavaScript code written directly inside an HTML file—such as <script>alert('xss')</script> or inline event handlers like <button onclick="doSomething()">.
When web applications allow inline scripts, they open the door to XSS. If an attacker manages to inject untrusted input into an HTML template, the browser will blindly execute it.
To prevent this, early CSP developers added 'unsafe-inline' to their policies so existing inline scripts would keep working. However, specifying 'unsafe-inline' effectively neutralizes XSS protection for scripts, leaving your application vulnerable. A modern CSP strictly forbids 'unsafe-inline'.
Step 1: Shift to External Script Files
The cleanest way to remove inline scripts is to decouple your JavaScript execution from your HTML markup.
1. Refactor Event Handlers
Instead of using inline HTML attributes:
HTML
Move the logic to an external JavaScript file and attach event listeners dynamically:
JavaScript
2. Move Inline Script Blocks to External Files
Instead of embedding configuration variables or initialization code directly in <script> tags, pass data through HTML data-* attributes or fetch JSON endpoints via JavaScript, and load the code using an external <script src="/path/to/script.js"></script> file.
Step 2: Use Nonces for Essential Dynamic Scripts
In complex applications, moving every single script to an external file during a refactor is not always immediately feasible. For cases where dynamically generated scripts are unavoidable, modern CSP uses a cryptographic token called a nonce (number used once).
A nonce is a random, unguessable string generated fresh by the backend server for every single HTTP request.
How a Nonce-Based Policy Works:
- Generate the Nonce: The backend server generates a cryptographically secure, random 128-bit string (encoded in base64) for every incoming request.
- Inject the Nonce in the Header: The server sets the CSP header, specifying the generated nonce.
- Attach the Nonce in the HTML: The server attaches the matching
nonceattribute to authorized<script>tags in the rendered HTML.
When the browser parses the HTML, it compares the nonce attribute on the script tag against the nonce in the CSP header. If they match, the script executes; if they do not, or if the attribute is missing, the script is blocked.
Example Server Implementation (Node.js/Express)
JavaScript
Example HTML Output
HTML
Step 3: Understand 'strict-dynamic'
In modern CSP, combining 'nonce-{RANDOM}' with 'strict-dynamic' simplifies script loading dramatically.
When 'strict-dynamic' is present in your policy:
- The browser trusts any script that contains a valid matching nonce.
- Furthermore, any trusted script is granted permission to dynamically load additional child scripts (for example, via
document.createElement('script')). - Browsers that support CSP Level 3 automatically ignore static domain allowlists and
'unsafe-inline', maintaining backward compatibility for older browsers while applying strict rules to modern ones.
This eliminates the hassle of having to explicitly allow every third-party domain or CDN endpoint inside your header directives.
Step 4: Add Fundamental Safeguards
A complete, modern CSP should lock down other potential attack vectors alongside script execution. Include these core directives in your HTTP response header:
object-src 'none';- Prevents the browser from executing legacy plugins like Flash, Java Applets, or ActiveX.
base-uri 'none';- Disables the manipulation of relative URLs through
<base>tags, preventing attackers from redirecting relative script fetches to malicious domains. frame-ancestors 'none';(or'self')- Prevents other websites from embedding your application inside
<iframe>tags, neutralizing clickjacking attacks.
Step 5: Test via Content-Security-Policy-Report-Only
Deploying a strict CSP without testing can unintentionally break site functionality if legacy inline scripts remain embedded in your codebase.
Before enforcing the policy, test it safely by setting the Content-Security-Policy-Report-Only header instead of the standard Content-Security-Policy header:
HTTP
In report-only mode, the browser will execute all scripts normally without blocking anything. However, whenever a rule violation occurs (such as an inline script lacking a valid nonce), the browser outputs a warning to the developer console and posts a JSON payload to your designated report-uri endpoint.
Once your endpoint logs confirm zero violation reports from legitimate application features, switch the header name back to Content-Security-Policy to activate strict enforcement in production.