Skip to main content

Command Palette

Search for a command to run...

XSS Attack Prevention: Content Security Policy

Published
9 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional XSS Prevention Fails in Modern Applications

Traditional XSS prevention relied heavily on server-side input sanitization and HTML entity encoding. This approach breaks down in 2025's application landscape for several critical reasons.

First, modern applications render content through complex JavaScript frameworks like React, Vue, and Svelte that manipulate the DOM programmatically. A single dangerouslySetInnerHTML call or improper use of v-html can bypass all server-side protections. Second, applications integrate dozens of third-party scripts—analytics, advertising, customer support widgets, A/B testing tools—each representing a potential injection vector. Third, the shift toward edge computing and client-side rendering means more application logic executes in environments where traditional server-side controls don't apply.

The rise of supply chain attacks compounds these challenges. The 2024 Polyfill.io incident demonstrated how compromised third-party scripts can inject malicious code into thousands of websites simultaneously. Input sanitization cannot protect against scripts you intentionally loaded that later become malicious.

Modern applications also face XSS risks from unexpected sources: user-generated content in collaborative tools, AI-generated responses that include executable code, dynamic imports from CDNs, and browser extensions that inject scripts. The attack surface has expanded beyond simple form inputs to encompass the entire content delivery and rendering pipeline.

Understanding Content Security Policy as a Security Boundary

Content Security Policy establishes a declarative security model where you explicitly define which resources the browser should trust. Rather than trying to sanitize every possible input, CSP creates an allowlist of legitimate content sources and execution contexts. When properly configured, CSP prevents XSS attacks even when malicious scripts reach the browser because the browser refuses to execute them.

CSP operates through HTTP response headers or meta tags that specify directives controlling resource loading and script execution. The browser enforces these policies before rendering content, creating a security boundary independent of application code. This separation of concerns means CSP protections remain effective even when application vulnerabilities exist.

The policy works by restricting several critical capabilities: where scripts can load from, whether inline scripts can execute, which domains can serve images or stylesheets, whether eval() and similar dynamic code execution functions work, and where the application can send data. Each restriction reduces the attack surface available to injected malicious code.

Implementing Modern CSP for XSS Prevention

Modern CSP implementation in 2025 centers on strict nonce-based or hash-based policies rather than domain allowlists. Domain-based CSP proved insufficient because attackers discovered numerous bypasses through JSONP endpoints, Angular template injection, and other techniques on allowlisted domains.

Here's a production-grade CSP implementation using nonces with Express and TypeScript:

import express, { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';

interface CSPRequest extends Request {
  cspNonce?: string;
}

// Middleware to generate and attach CSP nonce
export const cspNonceMiddleware = (
  req: CSPRequest,
  res: Response,
  next: NextFunction
): void => {
  const nonce = crypto.randomBytes(16).toString('base64');
  req.cspNonce = nonce;

  const cspDirectives = [
    "default-src 'self'",
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "object-src 'none'",
    "base-uri 'self'",
    "require-trusted-types-for 'script'",
    "trusted-types default",
    "upgrade-insecure-requests",
    "frame-ancestors 'none'"
  ];

  res.setHeader(
    'Content-Security-Policy',
    cspDirectives.join('; ')
  );

  next();
};

// Template rendering with nonce injection
export const renderWithCSP = (
  req: CSPRequest,
  res: Response,
  template: string,
  data: Record<string, unknown>
): void => {
  const nonce = req.cspNonce || '';

  // Inject nonce into script tags during server-side rendering
  const processedTemplate = template.replace(
    /<script(?!\s+src=)/g,
    `<script nonce="${nonce}"`
  );

  res.send(processedTemplate);
};

The strict-dynamic directive is crucial for modern applications. It allows scripts loaded by trusted scripts to execute, enabling dynamic imports and third-party SDK initialization without explicitly allowlisting every domain. This dramatically simplifies CSP maintenance while maintaining security.

For React applications using Next.js, implement CSP through middleware:

// middleware.ts
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 cspHeader = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' data: https:",
    "font-src 'self' data:",
    "connect-src 'self' https://api.yourdomain.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'"
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-CSP-Nonce', nonce);

  return response;
}

Then access the nonce in your components:

// app/layout.tsx
import { headers } from 'next/headers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const headersList = headers();
  const nonce = headersList.get('X-CSP-Nonce') || '';

  return (
    <html lang="en">
      <head>
        <script
          nonce={nonce}
          dangerouslySetInnerHTML={{
            __html: `
              window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
            `
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Implementing CSP Reporting for Violation Detection

CSP reporting provides visibility into policy violations, helping detect both attacks and legitimate functionality broken by overly restrictive policies. Modern CSP uses the Reporting API rather than the deprecated report-uri directive:

// Configure CSP with reporting
const cspWithReporting = [
  "default-src 'self'",
  `script-src 'nonce-${nonce}' 'strict-dynamic'`,
  "object-src 'none'",
  "base-uri 'self'",
  "report-to csp-endpoint"
].join('; ');

// Configure Reporting API endpoint
const reportingEndpoints = JSON.stringify({
  group: "csp-endpoint",
  max_age: 86400,
  endpoints: [
    { url: "https://your-domain.com/api/csp-reports" }
  ]
});

res.setHeader('Content-Security-Policy', cspWithReporting);
res.setHeader('Reporting-Endpoints', reportingEndpoints);

Implement a reporting endpoint to collect and analyze violations:

// api/csp-reports/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const CSPReportSchema = z.object({
  type: z.literal('csp-violation'),
  body: z.object({
    documentURL: z.string(),
    blockedURL: z.string().optional(),
    violatedDirective: z.string(),
    effectiveDirective: z.string(),
    originalPolicy: z.string(),
    disposition: z.enum(['enforce', 'report']),
    statusCode: z.number(),
  }),
});

export async function POST(request: NextRequest) {
  try {
    const reports = await request.json();

    for (const report of reports) {
      const validated = CSPReportSchema.parse(report);

      // Log to security monitoring system
      await logSecurityEvent({
        type: 'csp_violation',
        severity: determineSeverity(validated.body),
        details: validated.body,
        timestamp: new Date(),
        userAgent: request.headers.get('user-agent'),
        ip: request.headers.get('x-forwarded-for'),
      });

      // Alert on suspicious patterns
      if (isSuspiciousViolation(validated.body)) {
        await triggerSecurityAlert(validated.body);
      }
    }

    return NextResponse.json({ received: true }, { status: 204 });
  } catch (error) {
    console.error('CSP report processing error:', error);
    return NextResponse.json({ error: 'Invalid report' }, { status: 400 });
  }
}

function determineSeverity(violation: any): 'low' | 'medium' | 'high' {
  if (violation.violatedDirective.startsWith('script-src')) {
    return 'high';
  }
  if (violation.violatedDirective.startsWith('connect-src')) {
    return 'medium';
  }
  return 'low';
}

function isSuspiciousViolation(violation: any): boolean {
  const suspiciousPatterns = [
    /eval\(/,
    /javascript:/,
    /data:text\/html/,
    /\.ru\//,  // Adjust based on your threat model
  ];

  const blockedURL = violation.blockedURL || '';
  return suspiciousPatterns.some(pattern => pattern.test(blockedURL));
}

Handling Third-Party Scripts and Integrations

Third-party scripts present the biggest challenge for CSP implementation. Modern approaches use several strategies to maintain security while enabling necessary integrations.

For analytics and monitoring tools, use nonce-based loading:

// components/Analytics.tsx
interface AnalyticsProps {
  nonce: string;
}

export function Analytics({ nonce }: AnalyticsProps) {
  return (
    <>
      <script
        nonce={nonce}
        src="https://cdn.analytics-provider.com/v2/analytics.js"
        async
      />
      <script
        nonce={nonce}
        dangerouslySetInnerHTML={{
          __html: `
            window.analyticsConfig = {
              apiKey: '${process.env.NEXT_PUBLIC_ANALYTICS_KEY}',
              endpoint: 'https://api.analytics-provider.com'
            };
          `
        }}
      />
    </>
  );
}

For widgets that inject their own scripts, use sandboxed iframes with restricted permissions:

// components/ThirdPartyWidget.tsx
export function ThirdPartyWidget() {
  return (
    <iframe
      src="https://widget.thirdparty.com/embed"
      sandbox="allow-scripts allow-same-origin"
      title="Third Party Widget"
      style={{ border: 'none', width: '100%', height: '400px' }}
      // CSP for iframe content
      csp="default-src 'self' https://widget.thirdparty.com; script-src 'self' 'unsafe-inline' https://widget.thirdparty.com"
    />
  );
}

Trusted Types for DOM XSS Prevention

Trusted Types, now widely supported in 2025, complement CSP by preventing DOM-based XSS at the API level. Enable Trusted Types through CSP and implement policies:

// lib/trustedTypes.ts
export function initializeTrustedTypes(): void {
  if (typeof window === 'undefined' || !window.trustedTypes) {
    return;
  }

  const policy = window.trustedTypes.createPolicy('default', {
    createHTML: (input: string): string => {
      // Sanitize HTML using DOMPurify or similar
      return DOMPurify.sanitize(input, {
        ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
        ALLOWED_ATTR: ['href', 'title'],
      });
    },
    createScriptURL: (input: string): string => {
      // Validate script URLs against allowlist
      const allowedOrigins = [
        'https://cdn.yourdomain.com',
        'https://trusted-cdn.com',
      ];

      const url = new URL(input, window.location.origin);
      if (allowedOrigins.some(origin => url.origin === origin)) {
        return input;
      }

      throw new TypeError(`Untrusted script URL: ${input}`);
    },
    createScript: (input: string): string => {
      // Block inline scripts unless explicitly allowed
      throw new TypeError('Inline scripts not allowed');
    },
  });
}

Common Pitfalls and Edge Cases

Several implementation mistakes undermine CSP effectiveness. The most common is using unsafe-inline or unsafe-eval, which disable the primary XSS protections CSP provides. These directives should never appear in production policies.

Another frequent error is overly permissive script-src directives that allowlist entire CDNs. Attackers can often find JSONP endpoints or other exploitable resources on popular CDNs that bypass CSP. Use strict-dynamic with nonces instead of domain allowlists.

CSP deployment often breaks legitimate functionality. Common breakage points include:

  • Inline event handlers (onclick, onerror) that require refactoring to use addEventListener
  • Third-party widgets that inject inline scripts requiring iframe sandboxing
  • Browser extensions that inject content scripts, which CSP cannot control
  • Bookmarklets and browser developer tools that CSP intentionally blocks

Report-only mode helps identify these issues before enforcement:

// Deploy in report-only mode first
res.setHeader('Content-Security-Policy-Report-Only', cspDirectives.join('; '));

// After validating reports, switch to enforcement
res.setHeader('Content-Security-Policy', cspDirectives.join('; '));

CSP doesn't protect against all XSS vectors. It cannot prevent attacks that exploit legitimate functionality, such as stored XSS in user-generated content that uses allowed HTML tags, or XSS through CSS injection in environments where style-src 'unsafe-inline' is necessary. Defense in depth remains essential.

Best Practices for Production CSP Deployment

Start with a strict baseline policy and relax only when necessary with documented justification:

const strictBaselineCSP = [
  "default-src 'none'",
  `script-src 'nonce-${nonce}' 'strict-dynamic'`,
  `style-src 'nonce-${nonce}'`,
  "img-src 'self' data: https:",
  "font-src 'self'",
  "connect-src 'self'",
  "frame-ancestors 'none'",
  "base-uri 'none'",
  "form-action 'self'",
  "require-trusted-types-for 'script'",
  "upgrade-insecure-requests"
];

Implement CSP as infrastructure-as-code with version control and review processes. Changes to CSP should undergo the same scrutiny as code changes:

// config/csp.config.ts
export interface CSPConfig {
  environment: 'development' | 'staging' | 'production';
  directives: Record<string, string[]>;
  reportingEndpoint: string;
}

export const cspConfigs: Record<string, CSPConfig> = {
  production: {
    environment: 'production',
    directives: {
      'default-src': ["'self'"],
      'script-src': ["'nonce-{NONCE}'", "'strict-dynamic'"],
      'style-src': ["'nonce-{NONCE}'"],
      'img-src': ["'self'", "data:", "https:"],
      'connect-src': ["'self'", "https://api.yourdomain.com"],
      'frame-ancestors': ["'none'"],
      'base-uri': ["'self'"],
      'require-trusted-types-for': ["'script'"],
    },
    reportingEndpoint: 'https://csp-reports.yourdomain.com/api/reports',
  },
  // ... other environments
};

Monitor CSP violations continuously and establish alerting thresholds. Sudden spikes in violations may indicate attacks or deployment issues. Integrate CSP reporting with your security information and event management (SIEM) system.

Test CSP changes thoroughly in staging environments that mirror production traffic patterns. Use automated testing to verify CSP doesn't break critical user flows:

// tests/csp.test.ts
import { test, expect } from '@playwright/test';

test('CSP allows legitimate scripts', async ({ page }) => {
  const violations: any[] = [];

  page.on('console', msg => {
    if (msg.type() === 'error' && msg.text().includes('Content Security Policy')) {
      violations.push(msg.text());
    }
  });

  await page.goto('https://staging.yourdomain.com');
  await page.waitForLoadState('networkidle');

  // Verify no CSP violations for legitimate functionality
  expect(violations).toHaveLength(0);

  // Verify critical functionality works
  await page.click('[data-testid="submit-button"]');
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});

test('CSP blocks injected scripts', async ({ page }) => {
  await page.goto('https://staging.yourdomain.com');

  // Attempt to inject malicious script
  const result = await page.evaluate(() => {
    try {
      const script = document.createElement('script');
      script.textContent = 'window.xssTest = true;';
      document.body.appendChild(script);
      return window.xssTest === true;
    } catch (e) {
      return false;
    }
  });

  // Verify injection was blocked
  expect(result).toBe(false);
});

Maintain a CSP changelog documenting why each directive exists and when it was added. This prevents security erosion through incremental relaxation without understanding the original constraints.

FAQ

**What is Content Security Policy and how does it prevent