jsmanifest logojsmanifest

XSS Prevention in 7 Essential Techniques

XSS Prevention in 7 Essential Techniques

Learn how to protect your JavaScript applications from XSS attacks with practical code examples, security patterns, and real-world implementation strategies.

XSS Prevention in {/* REMOVED: JavaScript: */} 7 Essential Techniques

While I was looking over some production code the other day, I came across a comment form that made my stomach drop. The developer had used innerHTML to display user comments directly on the page. Little did I know at the time, but this single line of code was a ticking time bomb that could compromise every user on the site.

Understanding XSS Attacks: The Modern Web's Silent Threat

I was once guilty of thinking XSS attacks were something that only happened to big companies with lax security. Then I watched a demonstration where someone injected a simple script tag into a comment field and stole session cookies from every visitor who viewed that page. That's when I realized: XSS isn't just a theoretical vulnerability—it's a practical threat that can destroy user trust overnight.

Cross-Site Scripting happens when an attacker injects malicious scripts into web pages viewed by other users. The browser can't tell the difference between legitimate code and injected code, so it executes everything. I cannot stress this enough: if your application accepts any user input and displays it without proper handling, you're vulnerable.

The Three Types of XSS Vulnerabilities Every Developer Must Know

When I finally decided to take security seriously, I learned that XSS attacks come in three distinct flavors. Understanding these helped me identify vulnerabilities I'd been creating for years.

Stored XSS is the most dangerous. The malicious script gets saved in your database and executed every time someone views that data. Think comment sections, user profiles, or forum posts.

Reflected XSS happens when user input is immediately reflected back in the response. Search results that display "You searched for: [user input]" are classic examples.

DOM-based XSS occurs entirely in the client-side JavaScript without server involvement. When I was building single-page applications, I was especially guilty of this one—manipulating the DOM with unsanitized data from URL parameters or localStorage.

XSS Attack Vectors Diagram

Technique #1: Input Sanitization with DOMPurify

The first line of defense is sanitizing user input before it ever touches your DOM. I used to try rolling my own sanitization functions, which is a terrible idea. You'll miss edge cases that attackers know about.

DOMPurify is a battle-tested library that removes malicious code while preserving safe HTML. Here's how I use it in production:

import DOMPurify from 'dompurify';
 
// Bad: Never do this
function displayComment(userComment) {
  document.getElementById('comments').innerHTML = userComment;
  // An attacker could submit: <img src=x onerror="alert('XSS')">
}
 
// Good: Sanitize before displaying
function displayCommentSafely(userComment) {
  const clean = DOMPurify.sanitize(userComment, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
    ALLOWED_ATTR: ['href'],
    ALLOWED_URI_REGEXP: /^(?:https?|mailto):/
  });
  
  document.getElementById('comments').innerHTML = clean;
}
 
// Example usage
const maliciousInput = '<p>Great post!</p><script>steal_cookies()</script>';
displayCommentSafely(maliciousInput);
// Output: <p>Great post!</p>
// The script tag is completely removed

Notice how I explicitly whitelist allowed tags and attributes. This is crucial. In other words, I'm saying "only these specific things are allowed"—everything else gets stripped out.

Technique #2: Content Security Policy (CSP) Headers

Luckily we can add another layer of protection at the HTTP level. Content Security Policy headers tell the browser what sources of content are legitimate. When I first implemented CSP, I caught several third-party scripts I didn't even know were loading.

// Node.js/Express example
app.use((req, res, next) => {
  res.setHeader(
    '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' https://fonts.gstatic.com; " +
    "connect-src 'self' https://api.yourapp.com; " +
    "frame-ancestors 'none'; " +
    "base-uri 'self'; " +
    "form-action 'self'"
  );
  next();
});
 
// For static sites, add this meta tag to your HTML
// <meta http-equiv="Content-Security-Policy" 
//       content="default-src 'self'; script-src 'self' https://trusted-cdn.com">

This header blocks inline scripts by default, which stops most XSS attacks cold. Yes, it requires refactoring inline event handlers like onclick, but that's actually a wonderful side effect—it forces you to write better, more maintainable code.

Technique #3: Context-Aware Output Encoding

Here's something I wish someone had told me years ago: there's no single "encode everything" function that works everywhere. The encoding you need depends entirely on where you're putting the data.

// Different contexts require different encoding
class SecurityEncoder {
  // For HTML content
  static encodeHTML(str: string): string {
    return str
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#x27;')
      .replace(/\//g, '&#x2F;');
  }
 
  // For JavaScript strings
  static encodeJS(str: string): string {
    return str
      .replace(/\\/g, '\\\\')
      .replace(/'/g, "\\'")
      .replace(/"/g, '\\"')
      .replace(/\n/g, '\\n')
      .replace(/\r/g, '\\r')
      .replace(/\//g, '\\/');
  }
 
  // For URLs
  static encodeURL(str: string): string {
    return encodeURIComponent(str);
  }
}
 
// Example: Building a search results page
function displaySearchResults(query: string, results: string[]) {
  // In HTML content
  const htmlSafe = SecurityEncoder.encodeHTML(query);
  document.getElementById('query-display').textContent = 
    `Results for: ${htmlSafe}`;
 
  // In a JavaScript context (less common, but important)
  const jsSafe = SecurityEncoder.encodeJS(query);
  eval(`console.log("User searched for: ${jsSafe}");`); // Still avoid eval!
 
  // In a URL
  const urlSafe = SecurityEncoder.encodeURL(query);
  const shareLink = `https://yoursite.com/search?q=${urlSafe}`;
}

Technique #4: Avoiding Dangerous DOM Methods

This is where I made my biggest mistakes early on. The DOM API gives you several ways to insert content, and some are much safer than others.

const userInput = '<img src=x onerror="alert(\'XSS\')">';
 
// DANGEROUS - Executes scripts
element.innerHTML = userInput;
// Result: XSS attack succeeds
 
// SAFE - Treats everything as text
element.textContent = userInput;
// Result: Displays the literal string, no execution
 
// SAFE - Creates actual text node
const textNode = document.createTextNode(userInput);
element.appendChild(textNode);
// Result: Displays the literal string, no execution
 
// SAFE - Uses DOM methods to build structure
const paragraph = document.createElement('p');
paragraph.textContent = userInput;
element.appendChild(paragraph);
// Result: Text is safely contained in a paragraph element

When I finally decided to audit my codebase for innerHTML usage, I found dozens of potential vulnerabilities. Replacing them with textContent was tedious but straightforward.

Safe DOM Manipulation Methods

Techniques #5-7: HTTP-Only Cookies, Trusted Types API, and Framework-Level Protection

Let me share three more techniques that complete your XSS defense strategy.

Technique #5: HTTP-Only Cookies prevent JavaScript from accessing sensitive cookies. When I set session cookies, I always include the HttpOnly flag:

// Server-side cookie setting
res.cookie('sessionId', token, {
  httpOnly: true,      // Cannot be accessed by JavaScript
  secure: true,        // Only sent over HTTPS
  sameSite: 'strict'   // Prevents CSRF attacks
});

Technique #6: Trusted Types API is a newer browser feature that forces you to use safe APIs. It's still gaining adoption, but I'm starting to use it in new projects:

// Create a policy for sanitized HTML
const policy = window.trustedTypes.createPolicy('myPolicy', {
  createHTML: (string) => DOMPurify.sanitize(string)
});
 
// Now innerHTML requires a TrustedHTML object
element.innerHTML = policy.createHTML(userInput);

Technique #7: Framework-Level Protection is something modern frameworks handle automatically. React, Vue, and Angular escape values by default. But you can still shoot yourself in the foot:

// React example - SAFE by default
function Comment({ text }) {
  return <div>{text}</div>;  // Automatically escaped
}
 
// React - DANGEROUS override
function UnsafeComment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
  // Only use this with sanitized content!
}

Building an XSS-Proof Comment System: Real-World Implementation

Let me show you how I put all these techniques together in a real comment system:

import DOMPurify from 'dompurify';
 
interface Comment {
  id: string;
  author: string;
  content: string;
  timestamp: Date;
}
 
class SecureCommentSystem {
  private container: HTMLElement;
  
  constructor(containerId: string) {
    this.container = document.getElementById(containerId)!;
  }
 
  displayComment(comment: Comment): void {
    // Create wrapper with safe DOM methods
    const wrapper = document.createElement('article');
    wrapper.className = 'comment';
    wrapper.setAttribute('data-comment-id', comment.id);
 
    // Author name - plain text, no HTML allowed
    const authorEl = document.createElement('strong');
    authorEl.textContent = comment.author;
 
    // Timestamp - safe because it's a Date object
    const timeEl = document.createElement('time');
    timeEl.textContent = comment.timestamp.toLocaleString();
 
    // Content - sanitize if allowing rich text
    const contentEl = document.createElement('div');
    const sanitized = DOMPurify.sanitize(comment.content, {
      ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
      ALLOWED_ATTR: []
    });
    contentEl.innerHTML = sanitized;
 
    // Assemble the comment
    wrapper.appendChild(authorEl);
    wrapper.appendChild(timeEl);
    wrapper.appendChild(contentEl);
    
    this.container.appendChild(wrapper);
  }
 
  // Safe comment submission
  async submitComment(author: string, content: string): Promise<void> {
    // Client-side validation
    if (!author.trim() || !content.trim()) {
      throw new Error('Author and content are required');
    }
 
    // Send to server - server MUST also sanitize!
    const response = await fetch('/api/comments', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': this.getCSRFToken()
      },
      body: JSON.stringify({ author, content })
    });
 
    if (!response.ok) {
      throw new Error('Failed to submit comment');
    }
  }
 
  private getCSRFToken(): string {
    // Get CSRF token from meta tag
    const meta = document.querySelector('meta[name="csrf-token"]');
    return meta?.getAttribute('content') || '';
  }
}
 
// Usage
const commentSystem = new SecureCommentSystem('comments-container');
 
commentSystem.displayComment({
  id: '123',
  author: 'John Doe',
  content: '<p>Great article!</p><script>alert("XSS")</script>',
  timestamp: new Date()
});
// The script tag gets removed, but the paragraph stays

XSS Prevention Checklist and Security Audit Steps

Here's the checklist I use when auditing code for XSS vulnerabilities:

Input Handling:

  • All user input is sanitized with DOMPurify or equivalent
  • Input validation happens on both client and server
  • Maximum length limits are enforced

Output Encoding:

  • textContent is used instead of innerHTML where possible
  • Context-appropriate encoding is applied
  • No eval() or Function() constructor with user data

HTTP Headers:

  • Content-Security-Policy is configured
  • X-Content-Type-Options: nosniff is set
  • Cookies use HttpOnly, Secure, and SameSite flags

Framework Usage:

  • Dangerous framework methods are avoided
  • Props and state are properly validated
  • Third-party components are audited

DOM Manipulation:

  • No inline event handlers (onclick, etc.)
  • No document.write() with user data
  • URL parameters are validated before use

I run through this checklist during code reviews and before every deployment. Fascinating how many issues you catch when you have a systematic approach.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!