Introduction
Cross-Site Scripting (XSS) remains one of the most prevalent and dangerous web security vulnerabilities. According to OWASP, XSS consistently ranks among the top 10 web application security risks. An XSS attack allows an attacker to inject malicious scripts into web pages viewed by other users, enabling session hijacking, credential theft, data exfiltration, and defacement.
Content Security Policy (CSP) is a browser security mechanism that provides a powerful defense against XSS attacks. CSP works by declaring a whitelist of trusted content sources—scripts, styles, images, fonts, and other resources—that the browser is allowed to load. Any content not on the whitelist is blocked. This dramatically reduces the attack surface for XSS, even if an attacker manages to inject script code into your page.
CSP goes beyond simple source whitelisting. It can prevent inline script execution (the primary XSS vector), restrict form submission targets, control frame embedding, and report policy violations to a monitoring endpoint. This guide covers everything you need to implement a robust CSP: directive syntax, nonce-based and hash-based policies, reporting, and strategies for migrating existing applications.
Understanding CSP: Core Concepts
How CSP Works
CSP is delivered to the browser via an HTTP response header: Content-Security-Policy. The header value contains one or more directives, each controlling a specific type of resource. When the browser receives a page with CSP, it enforces the policy by blocking any resource that violates it.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'This policy says: load all resources from the same origin by default, allow scripts from the same origin and cdn.example.com, and allow styles from the same origin with inline styles permitted.
Key Directives
CSP defines directives for each type of resource the browser can load:
// CSP Directives Reference
const cspDirectives = {
// Resource loading directives
'default-src': 'Fallback for all resource types',
'script-src': 'Controls JavaScript execution',
'style-src': 'Controls CSS loading',
'img-src': 'Controls image loading',
'font-src': 'Controls font loading',
'connect-src': 'Controls fetch/XHR/WebSocket connections',
'media-src': 'Controls audio/video loading',
'object-src': 'Controls plugin content (Flash, etc.)',
'frame-src': 'Controls iframe sources',
'worker-src': 'Controls Worker/ServiceWorker sources',
// Document directives
'base-uri': 'Controls <base> element URL',
'form-action': 'Controls form submission targets',
'frame-ancestors': 'Controls who can embed this page',
// Navigation directives
'navigate-to': 'Controls where forms and links can navigate',
// Reporting directives
'report-uri': 'Endpoint for violation reports (deprecated)',
'report-to': 'Endpoint group for violation reports',
}Nonce-Based Script Execution
The most effective way to prevent XSS with CSP is using nonces (numbers used once). A nonce is a random, unpredictable value generated per request and included in both the CSP header and the script tag. The browser only executes scripts with a valid nonce, blocking any injected scripts that don't have it.
// Generating and applying nonces in a Node.js/Express app
import crypto from 'crypto'
function generateNonce(): string {
return crypto.randomBytes(16).toString('base64')
}
app.use((req, res, next) => {
const nonce = generateNonce()
res.locals.nonce = nonce
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`report-to csp-endpoint`
].join('; '))
next()
})
// In your template
app.get('/', (req, res) => {
res.send(`
<html>
<head>
<script nonce="${res.locals.nonce}" src="/app.js"></script>
</head>
<body>
<h1>Hello</h1>
</body>
</html>
`)
})Hash-Based Script Execution
Instead of nonces, you can use hashes of script content. The browser computes the hash of each inline script and compares it to the hash in the CSP header. This is useful for static pages where the script content doesn't change.
import crypto from 'crypto'
function computeHash(content: string): string {
const hash = crypto.createHash('sha256').update(content).digest('base64')
return `'sha256-${hash}'`
}
const inlineScript = 'console.log("Hello, World!")'
const scriptHash = computeHash(inlineScript)
const csp = `script-src 'self' ${scriptHash}`Architecture and Design Patterns
Next.js CSP Implementation
Modern frameworks like Next.js have built-in support for CSP through middleware or configuration. Here's a production-ready CSP implementation for Next.js:
// next.config.js
const crypto = require('crypto')
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [{
source: '/(.*)',
headers: [{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval'", // unsafe-eval needed for some frameworks
"style-src 'self' 'unsafe-inline'", // unsafe-inline for critical CSS
"img-src 'self' data: blob: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com wss://ws.example.com",
"media-src 'self'",
"object-src 'none'",
"frame-src 'self' https://www.youtube.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests"
].join('; ')
}]
}]
}
}
module.exports = nextConfigNonce-Based CSP with React Server Components
For React applications with server-side rendering, nonces must be generated per-request and passed to the client:
// middleware.ts (Next.js)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import crypto from 'crypto'
export function middleware(request: NextRequest) {
const nonce = crypto.randomBytes(16).toString('base64')
const response = NextResponse.next()
// Set CSP header with nonce
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; default-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; report-to csp-endpoint`
)
// Pass nonce to the page via header
response.headers.set('X-Nonce', nonce)
return response
}// app/layout.tsx
import { headers } from 'next/headers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
const headersList = headers()
const nonce = headersList.get('X-Nonce') || ''
return (
<html>
<head>
<script nonce={nonce} src="/analytics.js" />
</head>
<body>{children}</body>
</html>
)
}Reporting Violations
CSP violation reports are essential for monitoring and refining your policy. Use the report-to directive with the Reporting API to collect violation reports:
// Set up Reporting API endpoint
const reportToHeader = JSON.stringify({
group: 'csp-endpoint',
max_age: 86400,
endpoints: [
{ url: 'https://report.example.com/csp-reports' }
]
})
// Add to response headers
response.headers.set('Report-To', reportToHeader)
// CSP header includes report-to
response.headers.set(
'Content-Security-Policy',
`default-src 'self'; report-to csp-endpoint`
)
// Handle violation reports
app.post('/csp-reports', express.json({ type: 'application/reports+json' }), (req, res) => {
const reports = req.body
for (const report of reports) {
console.warn('CSP Violation:', {
documentUrl: report.body.documentURL,
violatedDirective: report.body.violatedDirective,
blockedUrl: report.body.blockedURL,
sourceFile: report.body.sourceFile,
lineNumber: report.body.lineNumber,
timestamp: report.body.timestamp
})
// Send to monitoring system
metrics.increment('csp.violation', {
directive: report.body.violatedDirective,
blocked: report.body.blockedURL
})
}
res.status(204).end()
})Step-by-Step Implementation
Phase 1: Report-Only Mode
Start with Content-Security-Policy-Report-Only to monitor violations without breaking your site:
// Start in report-only mode
function getCSPReportOnly(nonce: string): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' https://cdn.example.com https://www.google-analytics.com`,
`style-src 'self' 'nonce-${nonce}' https://fonts.googleapis.com`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com https://www.google-analytics.com`,
`frame-src 'self' https://www.youtube.com`,
`report-to csp-endpoint`
].join('; ')
}
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64')
res.locals.nonce = nonce
res.setHeader('Content-Security-Policy-Report-Only', getCSPReportOnly(nonce))
next()
})Phase 2: Enforce Policy
After analyzing report-only violations and fixing legitimate resources, switch to enforcement:
function getCSP(nonce: string): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`object-src 'none'`,
`frame-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
`report-to csp-endpoint`
].join('; ')
}Phase 3: Strict CSP
For maximum security, use a strict CSP that blocks all inline scripts and requires nonces for every script:
function getStrictCSP(nonce: string): string {
return [
`default-src 'none'`,
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'nonce-${nonce}'`,
`img-src 'self'`,
`font-src 'self'`,
`connect-src 'self'`,
`base-uri 'none'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
`report-to csp-endpoint`
].join('; ')
}The strict-dynamic directive tells the browser to trust scripts loaded by scripts with a valid nonce. This allows dynamically loaded scripts without whitelisting their URLs, which is more secure than URL-based whitelisting.
Real-World Use Cases
SaaS Application Security
SaaS applications handle sensitive user data and are prime targets for XSS attacks. A strict CSP with nonces ensures that even if an attacker finds an injection point, they cannot execute arbitrary scripts. The reporting endpoint provides visibility into attempted attacks.
E-Commerce Checkout
E-commerce checkout pages are high-value targets. CSP prevents attackers from injecting scripts that steal credit card numbers or redirect payments. The form-action directive ensures forms can only submit to trusted endpoints.
Banking and Financial Applications
Financial applications require the strictest security controls. CSP with frame-ancestors 'none' prevents clickjacking attacks. Combined with nonces and strict-dynamic, this creates a robust defense against script injection.
Content Management Systems
CMS platforms like WordPress are frequent XSS targets. CSP can be applied at the web server level to protect all sites on the platform, with reporting to identify and fix vulnerabilities.
Best Practices for Production
-
Start with report-only: Never enforce CSP immediately. Use
Content-Security-Policy-Report-Onlyto collect violations and refine your policy before enforcement. -
Use nonces over hashes: Nonces are simpler to implement with dynamic content. Generate a unique nonce per request and include it in both the CSP header and script tags.
-
Avoid
unsafe-inlinefor scripts:unsafe-inlinedefeats the purpose of CSP for script-src. Use nonces or hashes instead. For styles,unsafe-inlineis often acceptable due to the prevalence of inline styles in frameworks. -
Avoid
unsafe-eval:eval()and similar functions are XSS vectors. If you must use them (some frameworks require it), document why and consider alternatives. -
Use
strict-dynamicfor third-party scripts: Instead of whitelisting every third-party script URL, usestrict-dynamicto trust scripts loaded by your trusted scripts. -
Monitor violations continuously: Set up a violation reporting endpoint and monitor it for unexpected violations. Sudden spikes may indicate an attack or a broken deployment.
-
Test in staging first: Deploy CSP to a staging environment and verify that all functionality works before production deployment.
-
Use a CSP generator tool: Tools like CSP Evaluator help validate your policy and identify weaknesses.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Using unsafe-inline for scripts | CSP bypassed by XSS | Use nonces or hashes |
| Whitelisting too many domains | Large attack surface | Minimize domains, use strict-dynamic |
Not handling report-to endpoint | No visibility into violations | Set up reporting from day one |
| Breaking existing inline scripts | Functionality loss | Migrate scripts to external files or add nonces |
Forgetting upgrade-insecure-requests | Mixed content warnings | Always include this directive |
Using old report-uri instead of report-to | Deprecated behavior | Use report-to with Reporting API |
| Not CSP protecting admin pages | Admin XSS attacks | Apply CSP to all pages, including admin |
| Static nonce in config file | Nonce predictable, CSP bypassed | Generate nonce per request at runtime |
Performance Optimization
CSP adds minimal overhead—a single HTTP header per response. The nonce generation and header construction add microseconds to request processing. The browser's CSP evaluation is highly optimized and adds negligible overhead to resource loading.
// Pre-compute static parts of CSP header
const CSP_STATIC_PARTS = [
"default-src 'self'",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
"report-to csp-endpoint"
].join('; ')
// Per-request: only add nonce-dependent directives
function getCSP(nonce: string): string {
return `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; ${CSP_STATIC_PARTS}`
}Comparison with Alternatives
| Mechanism | XSS Prevention | Granularity | Browser Support | Ease of Implementation |
|---|---|---|---|---|
| CSP with Nonces | Strong | Per-request | All modern browsers | Moderate |
| CSP with Hashes | Strong | Per-script | All modern browsers | Moderate |
| CSP with URLs | Moderate | Per-domain | All modern browsers | Easy |
| Trusted Types | Strong | Per-sink | Chrome, Edge | Moderate |
| Sanitization (DOMPurify) | Moderate | Per-output | All browsers | Easy |
| Input Validation | Weak | Per-input | All browsers | Easy |
Advanced Patterns
Multi-Domain CSP
For applications serving multiple domains or subdomains, configure CSP to allow the appropriate origins:
function getCSPForTenant(tenant: string, nonce: string): string {
const tenantDomain = `${tenant}.example.com`
return [
`default-src 'self' https://${tenantDomain}`,
`script-src 'self' 'nonce-${nonce}' https://${tenantDomain} https://cdn.example.com`,
`style-src 'self' 'nonce-${nonce}' https://${tenantDomain}`,
`connect-src 'self' https://${tenantDomain} https://api.example.com`,
`report-to csp-endpoint`
].join('; ')
}CSP with Subresource Integrity
Combine CSP with Subresource Integrity (SRI) to ensure that third-party scripts haven't been tampered with:
<script
src="https://cdn.example.com/library.js"
integrity="sha384-abc123..."
crossorigin="anonymous"
nonce="${nonce}"
></script>Content-Security-Policy: script-src 'self' 'nonce-${nonce}' https://cdn.example.comTesting Strategies
Test CSP enforcement by attempting to execute inline scripts and load resources from unauthorized origins:
// Cypress test for CSP
describe('CSP Enforcement', () => {
it('blocks inline scripts without nonce', () => {
cy.visit('/test-page')
// Check that CSP header is present
cy.request('/').its('headers').should('have.property', 'content-security-policy')
// Verify inline scripts are blocked
cy.window().then(win => {
// This should be blocked by CSP
const script = win.document.createElement('script')
script.textContent = 'window.__xss__ = true'
win.document.body.appendChild(script)
// Wait and check
cy.wait(500)
expect(win.__xss__).to.be.undefined
})
})
it('allows scripts with valid nonce', () => {
cy.visit('/test-page')
cy.window().then(win => {
const nonce = win.document.querySelector('script[nonce]')?.getAttribute('nonce')
expect(nonce).to.not.be.empty
const script = win.document.createElement('script')
script.setAttribute('nonce', nonce)
script.textContent = 'window.__valid__ = true'
win.document.body.appendChild(script)
cy.wait(500)
expect(win.__valid__).to.be.true
})
})
})Future Outlook
CSP continues to evolve with new directives and integration with other security mechanisms like Trusted Types. The Trusted Types API, now supported in Chrome and Edge, prevents DOM XSS by requiring typed objects for dangerous sinks like innerHTML and eval(). Combined with CSP, Trusted Types provides defense-in-depth against XSS.
The Reporting API is also evolving, with the new report-to directive replacing the deprecated report-uri. The Reporting API provides a standardized way to collect violation reports alongside other browser diagnostics like crash reports and deprecation warnings.
CSP Reporting and Monitoring
Deploy CSP in report-only mode first using Content-Security-Policy-Report-Only to collect violation reports without breaking functionality. Configure a reporting endpoint using the report-uri or report-to directive to receive violation reports. Analyze reports to identify legitimate policy violations from your application code versus attempted attacks. Use services like Sentry, Report URI, or Google's CSP Evaluator to aggregate and analyze violation reports. Gradually tighten your policy by adding stricter directives as you fix violations, eventually switching from report-only to enforcement mode.
Advanced CSP Techniques
Implement strict CSP policies using cryptographic nonces generated per-request on the server. The nonce approach is more secure than hash-based allowlisting because each nonce is unique and cannot be reused. Use the strict-dynamic directive to allow scripts loaded by trusted scripts to execute without individual allowlisting. Implement CSP for email content using the sandbox directive to restrict what embedded content can do. Use the navigate-to directive to control where forms and links can submit data, preventing data exfiltration to attacker-controlled domains.
Conclusion
Content Security Policy is the most powerful browser-based defense against XSS attacks. By declaring a whitelist of trusted content sources and requiring nonces for script execution, CSP makes it significantly harder for attackers to exploit XSS vulnerabilities.
Key takeaways:
- Use nonces for script-src—they're the most effective way to prevent inline script injection while allowing legitimate scripts to execute.
- Start with report-only mode—collect violations and refine your policy before enforcing it, avoiding disruption to users.
- Use
strict-dynamicfor third-party scripts—instead of maintaining a URL whitelist that can grow unwieldy and create attack surface. - Monitor violation reports continuously—they provide visibility into both attacks and legitimate resources that need to be added to your policy.
Start by adding a report-only CSP header to your application, analyzing the violations, and progressively tightening the policy. Refer to the CSP documentation on MDN and the CSP Evaluator for policy validation.