Frontend Security: XSS Prevention and CSP

Cross-Site Scripting (XSS) attacks occur when attackers inject malicious scripts into web applications that execute in other users' browsers. Despite being well-understood for decades, XSS...

Key Insights

  • XSS attacks remain one of the most common web vulnerabilities, but modern frameworks provide built-in protections that developers often bypass with dangerous patterns like dangerouslySetInnerHTML or v-html.
  • Content Security Policy acts as a critical second line of defense by restricting what scripts can execute, even if an attacker manages to inject malicious code into your application.
  • A defense-in-depth approach combining input sanitization, secure coding practices, and a strict CSP policy reduces XSS risk by over 95% compared to relying on any single technique alone.

Introduction to XSS Vulnerabilities

Cross-Site Scripting (XSS) attacks occur when attackers inject malicious scripts into web applications that execute in other users’ browsers. Despite being well-understood for decades, XSS consistently ranks in the OWASP Top 10 because developers continue to make the same fundamental mistakes.

There are three main types of XSS attacks:

Reflected XSS happens when user input is immediately returned in the response without proper sanitization. A malicious link contains the attack payload, and the server reflects it back to the victim.

Stored XSS is more dangerous—the malicious script is permanently stored on the server (in a database, comment system, or user profile) and executed whenever other users view the infected content.

DOM-based XSS occurs entirely client-side when JavaScript reads user-controlled data and writes it to the DOM without proper validation.

Here’s a simple vulnerable example:

// VULNERABLE: Never do this
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('name');
document.getElementById('greeting').innerHTML = `Hello, ${username}!`;

An attacker could craft a URL like ?name=<img src=x onerror=alert(document.cookie)> and steal session cookies from any user who clicks the link.

XSS Prevention Techniques

The first rule of XSS prevention: never trust user input. This applies to URL parameters, form data, cookies, and even data from your own database (which may have been compromised).

Input Validation and Output Encoding

Input validation should be strict and context-aware. However, validation alone isn’t sufficient—you must also encode output based on where it’s being inserted.

// SECURE: Using textContent instead of innerHTML
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('name');
document.getElementById('greeting').textContent = `Hello, ${username}!`;

The textContent API automatically escapes HTML entities, preventing script execution. This is the single most important change you can make.

Framework-Specific Protections

Modern frameworks like React, Vue, and Angular provide automatic escaping by default:

// React - SECURE by default
function Greeting({ username }) {
  return <div>Hello, {username}!</div>;
}

// React - VULNERABLE when you bypass protections
function DangerousGreeting({ htmlContent }) {
  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}
<!-- Vue - SECURE by default -->
<div>Hello, {{ username }}!</div>

<!-- Vue - VULNERABLE when you bypass protections -->
<div v-html="htmlContent"></div>

Only use dangerouslySetInnerHTML, v-html, or [innerHTML] when absolutely necessary, and always sanitize the content first.

Using DOMPurify for Rich Content

When you need to render user-generated HTML (like in a WYSIWYG editor), use a battle-tested sanitization library:

import DOMPurify from 'dompurify';

// Sanitize user-generated HTML
const dirtyHTML = userInput;
const cleanHTML = DOMPurify.sanitize(dirtyHTML, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href']
});

document.getElementById('content').innerHTML = cleanHTML;

DOMPurify removes dangerous elements and attributes while preserving safe formatting. Configure it based on your specific needs—the more restrictive, the better.

Content Security Policy (CSP) Fundamentals

Even with perfect input sanitization, bugs happen. Content Security Policy provides a crucial second layer of defense by controlling what resources the browser is allowed to load and execute.

CSP is implemented via HTTP headers:

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.example.com; frame-ancestors 'none';

Let’s break down the key directives:

  • default-src 'self': Only load resources from the same origin by default
  • script-src 'self' https://trusted-cdn.com: Scripts only from your domain and specified CDN
  • style-src 'self' 'unsafe-inline': Styles from your domain plus inline styles (not ideal, but common)
  • img-src 'self' data: https:: Images from your domain, data URIs, and any HTTPS source
  • connect-src 'self' https://api.example.com: AJAX/WebSocket connections restricted
  • frame-ancestors 'none': Prevent your site from being embedded in iframes

You can also set CSP via meta tags, though HTTP headers are preferred:

<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; script-src 'self'">

Start with a restrictive policy in report-only mode to identify what needs to be allowed:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violation-report

Advanced CSP Strategies

The 'unsafe-inline' directive defeats much of CSP’s purpose because it allows inline scripts—exactly what attackers inject. Instead, use nonces or hashes.

Nonces for Inline Scripts

A nonce is a cryptographically random value generated for each request:

// Server-side (Express example)
const crypto = require('crypto');

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  res.setHeader(
    'Content-Security-Policy',
    `script-src 'self' 'nonce-${res.locals.nonce}'`
  );
  next();
});
<!-- In your template -->
<script nonce="<%= nonce %>">
  // This inline script will execute
  console.log('Allowed by nonce');
</script>

<script>
  // This inline script will be blocked (no nonce)
  console.log('Blocked!');
</script>

Script Hashes

For static inline scripts, use hashes instead of nonces:

<script>
  console.log('Static script');
</script>

Generate the SHA-256 hash of the script content and add it to your CSP:

Content-Security-Policy: script-src 'self' 'sha256-abc123...'

The strict-dynamic Directive

Modern CSP policies use 'strict-dynamic' to trust scripts loaded by already-trusted scripts:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none';

This allows your nonce-approved scripts to load additional scripts dynamically, while still blocking injected inline scripts.

CSP Violation Reporting

Monitor CSP violations to catch attacks and policy misconfigurations:

Content-Security-Policy: default-src 'self'; report-uri /csp-report; report-to csp-endpoint
// Express endpoint to receive reports
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  console.log('CSP Violation:', req.body);
  // Log to your monitoring service
  res.status(204).end();
});

Common Pitfalls and Best Practices

Avoid These Unsafe Patterns

Don’t use 'unsafe-eval' unless absolutely necessary. It allows eval(), Function(), and setTimeout(string), which are common XSS vectors:

// BAD: Requires 'unsafe-eval'
const code = userInput;
eval(code);  // Never do this

// GOOD: Use safe alternatives
const data = JSON.parse(userInput);

Don’t whitelist data: or https: for scripts. This effectively disables CSP:

# TOO PERMISSIVE
script-src https:;  # Allows scripts from ANY HTTPS source

Don’t forget about base-uri. Without it, attackers can inject <base> tags to hijack relative URLs:

Content-Security-Policy: base-uri 'self';

Testing Your CSP

Use browser DevTools to debug CSP violations. Chrome and Firefox show detailed violation messages in the Console.

Test with tools like Google’s CSP Evaluator (csp-evaluator.withgoogle.com) to identify weak policies.

// Automated CSP testing
describe('CSP Headers', () => {
  it('should include strict CSP policy', async () => {
    const response = await fetch('/');
    const csp = response.headers.get('content-security-policy');
    expect(csp).toContain("default-src 'self'");
    expect(csp).not.toContain("'unsafe-inline'");
    expect(csp).not.toContain("'unsafe-eval'");
  });
});

Migration Strategy for Legacy Apps

Start with CSP in report-only mode, collect violations for a week, then iteratively tighten the policy:

  1. Deploy Content-Security-Policy-Report-Only with a permissive policy
  2. Analyze violation reports to understand your application’s needs
  3. Refactor inline scripts to use nonces or external files
  4. Switch to enforcing mode with Content-Security-Policy
  5. Gradually remove 'unsafe-inline' and 'unsafe-eval'

Conclusion and Checklist

XSS prevention requires a defense-in-depth approach. No single technique is bulletproof, but combining multiple layers makes successful attacks exponentially harder.

Essential Security Checklist:

  • Use textContent or framework defaults instead of innerHTML
  • Sanitize user-generated HTML with DOMPurify before rendering
  • Implement a strict CSP with default-src 'self'
  • Use nonces or hashes for inline scripts instead of 'unsafe-inline'
  • Set base-uri 'self' and object-src 'none'
  • Enable CSP violation reporting
  • Validate and sanitize all user input server-side
  • Use HTTPS everywhere to prevent man-in-the-middle attacks
  • Set HttpOnly and Secure flags on cookies
  • Regularly audit dependencies for known vulnerabilities

Recommended Tools:

  • DOMPurify for HTML sanitization
  • Google CSP Evaluator for policy analysis
  • Mozilla Observatory for comprehensive security scanning
  • OWASP ZAP for penetration testing

Security is not a one-time implementation—it’s an ongoing process. Review your CSP policies quarterly, stay updated on new attack vectors, and always assume that user input is malicious until proven otherwise. The combination of secure coding practices and a properly configured CSP will protect your users from the vast majority of XSS attacks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.