Why Content Security Policy Headers Still Matter in 2026
Cross-site scripting (XSS) remains one of the most exploited vulnerabilities on the web, and content security policy headers are still the most effective browser-native defense against it. A well-crafted CSP tells the browser exactly which scripts, styles, images, and connections it should trust, and blocks everything else by default.
The problem? Most CSPs deployed in production are either too permissive (using unsafe-inline and unsafe-eval, which defeats the purpose) or so strict they break the site on day one. This guide walks you through a hands-on migration from a loose CSP to a strict, nonce-based one, with copy-paste examples for Nginx and Express.

What a Content Security Policy Header Actually Does
The Content-Security-Policy HTTP response header is sent by your server on every page request. The browser parses it and enforces the rules before executing any resource. If a script, iframe, or fetch call violates a directive, the browser refuses to load or execute it.
Here is a minimal example:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
That one line blocks inline scripts, external scripts from untrusted origins, Flash-style plugins, base tag hijacking, and clickjacking. Not bad for a single header.

Core CSP Directives You Need to Know
| Directive | Purpose | Recommended Value |
|---|---|---|
| default-src | Fallback for all fetch directives | 'self' |
| script-src | Controls JavaScript sources | 'self' 'nonce-{random}' 'strict-dynamic' |
| style-src | Controls CSS sources | 'self' 'nonce-{random}' |
| img-src | Controls image sources | 'self' data: https: |
| connect-src | Controls fetch, XHR, WebSocket | 'self' https://api.yourdomain.com |
| font-src | Controls font sources | 'self' https://fonts.gstatic.com |
| frame-ancestors | Anti-clickjacking (replaces X-Frame-Options) | 'none' or 'self' |
| base-uri | Restricts <base> tag | 'self' |
| object-src | Blocks plugins | 'none' |
| form-action | Restricts form submission targets | 'self' |
| upgrade-insecure-requests | Auto-upgrades HTTP to HTTPS | (no value) |
The 5-Step Migration Plan from Permissive to Strict CSP
- Audit what your site currently loads (scripts, styles, fonts, APIs, images).
- Deploy in Report-Only mode to collect violations without breaking anything.
- Fix inline scripts and styles by moving them to files or adding nonces.
- Switch to enforcement mode once reports are clean.
- Iterate toward a strict CSP with nonces and
strict-dynamic.
Step 1: Audit Your Site
Open DevTools, go to the Network tab, and list every domain your pages load. Also grep your codebase for <script>, onclick=, style=, and eval(. Every one of these is a future CSP violation.
Step 2: Start with Report-Only
Use the Content-Security-Policy-Report-Only header to log violations without blocking anything:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint
Set up a report-to endpoint (or use a service like Report URI or Sentry) to collect JSON reports. Run this for at least a week to catch edge cases. See mozilla.org for their take.
Step 3: Kill Inline Code with Nonces
A nonce is a random token generated per request. You add it to the CSP header and to every trusted inline script or style tag. Attackers cannot guess the nonce, so injected scripts get blocked.
Express Example with Nonces
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; ` +
`script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic'; ` +
`style-src 'self' 'nonce-${res.locals.nonce}'; ` +
`img-src 'self' data: https:; ` +
`connect-src 'self'; ` +
`object-src 'none'; ` +
`base-uri 'self'; ` +
`frame-ancestors 'none'; ` +
`form-action 'self'; ` +
`upgrade-insecure-requests`
);
next();
});
app.get('/', (req, res) => {
res.send(`<!doctype html><html><head>
<script nonce="${res.locals.nonce}">console.log('trusted');</script>
</head><body>Hello</body></html>`);
});
app.listen(3000);
If you use a template engine like EJS or Pug, pass nonce to the view and inject it into every inline tag. See owasp.org for their take.
Nginx Example
Nginx cannot generate per-request nonces natively, so you either offload nonce generation to your app or use a static (but still strict) CSP for pure static sites:
server {
listen 443 ssl http2;
server_name yourdomain.com;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
root /var/www/yourdomain;
try_files $uri $uri/ =404;
}
}
For dynamic apps behind Nginx, let the upstream app (Node, PHP, Python) set the CSP header and use proxy_pass_header so Nginx does not strip it.
Step 4: Enforce the Policy
Once your report endpoint is quiet for a few days, rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the report-to directive so you catch regressions.
Step 5: Go Strict with strict-dynamic
Modern strict CSPs use this pattern:
script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
Yes, 'unsafe-inline' is in there, but it is ignored by browsers that support 'strict-dynamic'. It only exists as a fallback for legacy browsers. This approach is recommended by Google and OWASP because it stops relying on domain allowlists (which are notoriously bypassable).

Common Pitfalls to Avoid
- Using
unsafe-inlinepermanently: it neutralizes CSP against XSS. Use nonces instead. - Wildcarding
script-src *: equivalent to no CSP at all for scripts. - Forgetting
base-uri: attackers can inject a<base>tag to hijack relative URLs. - Forgetting
frame-ancestors: you lose clickjacking protection. - Reusing the same nonce across requests: it must be regenerated on every response.
- Applying CSP only to HTML pages: also protect JSON and API responses with
default-src 'none'. - Ignoring browser extensions in reports: filter out
chrome-extension://andmoz-extension://noise. - Setting CSP in a
<meta>tag: some directives (likeframe-ancestorsandreport-to) only work as HTTP headers.
Testing Your CSP
- Use Google CSP Evaluator to score your policy.
- Use Mozilla Observatory for a full security header audit.
- Check DevTools Console for
Refused to executeorRefused to loadmessages. - Run automated end-to-end tests with the enforced policy in a staging environment.

Recommended Strict CSP Template for 2026
Content-Security-Policy:
default-src 'none';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
style-src 'self' 'nonce-{RANDOM}';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.yourdomain.com;
form-action 'self';
frame-ancestors 'none';
base-uri 'self';
object-src 'none';
upgrade-insecure-requests;
report-to csp-endpoint
FAQ
What is the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?
The enforcement header blocks violations. The report-only variant logs them without blocking, which is ideal for testing new policies safely in production.
Can I set multiple Content-Security-Policy headers?
Yes. Browsers apply the intersection of all policies, meaning the strictest rule wins. This is useful when a CDN adds a baseline policy and your app adds page-specific tightening.
Are nonces better than hashes?
Nonces are more flexible for dynamic content because they change per request. Hashes are better for static inline snippets that never change. Many sites use both.
Does X-Content-Security-Policy still work?
No. X-Content-Security-Policy and X-Webkit-CSP are legacy headers from early browser implementations and should be removed. Use only Content-Security-Policy.
Will CSP slow down my site?
No measurable performance impact. The header adds a few hundred bytes and browsers parse it once per response.
How do I handle third-party scripts like analytics or ads?
Prefer 'strict-dynamic' so trusted loaders can pull in their own dependencies without you allowlisting every domain. If you must allowlist, list specific subdomains, not wildcards.
Wrapping Up
Deploying content security policy headers correctly is not about copying a snippet from Stack Overflow. It is a staged migration: audit, report, refactor, enforce, and iterate. Start in report-only mode today, kill your inline scripts this week, and by next month your site will have one of the strongest XSS defenses available in a modern browser. threatngsecurity.com has a solid rundown on this.
Bookmark this guide, test with Google CSP Evaluator, and if you deploy to Nginx or Express, the snippets above are production-ready. Ship it.

