MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Content Security Policy (CSP): Preventing XSS

Implement CSP: directives, nonce, hash, reporting, and migration strategies.

CSPSecurityXSSWeb Security

By MinhVo

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.

Web security architecture

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.

Browser security model

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 = nextConfig

Nonce-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>
  )
}

Security policy enforcement

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

  1. Start with report-only: Never enforce CSP immediately. Use Content-Security-Policy-Report-Only to collect violations and refine your policy before enforcement.

  2. 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.

  3. Avoid unsafe-inline for scripts: unsafe-inline defeats the purpose of CSP for script-src. Use nonces or hashes instead. For styles, unsafe-inline is often acceptable due to the prevalence of inline styles in frameworks.

  4. Avoid unsafe-eval: eval() and similar functions are XSS vectors. If you must use them (some frameworks require it), document why and consider alternatives.

  5. Use strict-dynamic for third-party scripts: Instead of whitelisting every third-party script URL, use strict-dynamic to trust scripts loaded by your trusted scripts.

  6. 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.

  7. Test in staging first: Deploy CSP to a staging environment and verify that all functionality works before production deployment.

  8. Use a CSP generator tool: Tools like CSP Evaluator help validate your policy and identify weaknesses.

Common Pitfalls and Solutions

PitfallImpactSolution
Using unsafe-inline for scriptsCSP bypassed by XSSUse nonces or hashes
Whitelisting too many domainsLarge attack surfaceMinimize domains, use strict-dynamic
Not handling report-to endpointNo visibility into violationsSet up reporting from day one
Breaking existing inline scriptsFunctionality lossMigrate scripts to external files or add nonces
Forgetting upgrade-insecure-requestsMixed content warningsAlways include this directive
Using old report-uri instead of report-toDeprecated behaviorUse report-to with Reporting API
Not CSP protecting admin pagesAdmin XSS attacksApply CSP to all pages, including admin
Static nonce in config fileNonce predictable, CSP bypassedGenerate 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

MechanismXSS PreventionGranularityBrowser SupportEase of Implementation
CSP with NoncesStrongPer-requestAll modern browsersModerate
CSP with HashesStrongPer-scriptAll modern browsersModerate
CSP with URLsModeratePer-domainAll modern browsersEasy
Trusted TypesStrongPer-sinkChrome, EdgeModerate
Sanitization (DOMPurify)ModeratePer-outputAll browsersEasy
Input ValidationWeakPer-inputAll browsersEasy

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.com

Testing 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:

  1. Use nonces for script-src—they're the most effective way to prevent inline script injection while allowing legitimate scripts to execute.
  2. Start with report-only mode—collect violations and refine your policy before enforcing it, avoiding disruption to users.
  3. Use strict-dynamic for third-party scripts—instead of maintaining a URL whitelist that can grow unwieldy and create attack surface.
  4. 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.