JavaScript XSS Prevention: Complete Guide

Learn how to prevent Cross-Site Scripting (XSS) attacks in JavaScript. Protect your users with output encoding, CSP headers, safe DOM APIs, and input sanitization.

8 min read

Cross-Site Scripting (XSS) is an attack that injects malicious scripts into web pages viewed by other users. An attacker finds a way to insert JavaScript into your page. When another user visits that page, the attacker's script runs in their browser, in your application's context.

The script can steal cookies, capture keystrokes, redirect the user to a phishing site, or make requests to your API as the victim. XSS has been in the OWASP Top 10 web security risks for over two decades.

How XSS Works

The simplest example: a search page that echoes the user's query back into the page using innerHTML:

javascriptjavascript
// Vulnerable code
const query = new URLSearchParams(location.search).get("q");
document.getElementById("results").innerHTML = `Results for: ${query}`;

An attacker sends a link with a crafted query parameter:

texttext
https://example.com/search?q=<img src=x onerror="alert('XSS')">

The page renders the attacker's img tag. The browser tries to load src="x", which fails. The onerror handler fires. The attacker's JavaScript executes.

This is reflected XSS: the malicious payload comes from the request and is reflected into the response. The attacker tricks a victim into clicking the link, and the script runs in the victim's browser.

The vulnerable line is the innerHTML assignment. Any time you set innerHTML with untrusted strings, you open the door to injection. The rest of this article shows how to keep that door shut.

Rule 1: Escape HTML Output

The fundamental defense is output encoding. Before inserting any untrusted string into HTML, convert special characters to their entity equivalents:

javascriptjavascript
function escapeHtml(str) {
  const entityMap = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#x27;"
  };
  return str.replace(/[&<>"']/g, char => entityMap[char]);
}

Now the search page is safe:

javascriptjavascript
const query = new URLSearchParams(location.search).get("q");
document.getElementById("results").innerHTML =
  `Results for: ${escapeHtml(query)}`;

When the attacker submits <img src=x onerror="alert('XSS')">, the browser renders it as text:

texttext
Results for: &lt;img src=x onerror=&quot;alert(&#x27;XSS&#x27;)&quot;&gt;

The user sees the angle brackets. The browser does not interpret them as HTML. This is the most important habit in XSS prevention: every string that comes from outside your code must be escaped before it touches the DOM.

Rule 2: Use Safe DOM APIs

innerHTML is the most dangerous DOM API because it parses strings as HTML. Safer alternatives exist for every use case:

Instead ofUseWhy it is safe
el.innerHTML = strel.textContent = strTreats content as text, never HTML
el.innerHTML = strel.innerText = strSame as textContent with layout awareness
el.innerHTML = "<b>text</b>"el.appendChild(document.createElement(...))Builds DOM nodes directly
el.setAttribute("onclick", str)el.addEventListener("click", fn)Event listeners, not attribute strings
document.write(str)Never use document.writeIt has no safe use with untrusted input
href="javascript:..."Use # or button with event listenerjavascript: URLs are executable

Example: building a list from untrusted data the safe way:

javascriptjavascript
// Unsafe
function renderUsersUnsafe(users) {
  const ul = document.getElementById("user-list");
  ul.innerHTML = users.map(u => `<li>${u.name}</li>`).join("");
}
 
// Safe
function renderUsersSafe(users) {
  const ul = document.getElementById("user-list");
  ul.textContent = ""; // Clear existing content
 
  users.forEach(u => {
    const li = document.createElement("li");
    li.textContent = u.name; // textContent escapes automatically
    ul.appendChild(li);
  });
}

The safe version creates elements programmatically. Each textContent assignment is automatically safe. No string interpolation touches the DOM as HTML.

Rule 3: Content Security Policy

Content Security Policy (CSP) is an HTTP header that tells the browser which sources of JavaScript are allowed. It is your last line of defense:

httphttp
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'

This policy says: scripts can only come from the same origin ('self'), and inline scripts (like onerror="...") are blocked. Even if an attacker manages to inject a script tag, the browser refuses to execute it.

A stricter policy for a modern app:

httphttp
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{random}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' https: data:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';

Key directives:

DirectiveWhat it controls
script-srcWhere JavaScript can load from
style-srcWhere CSS can load from
img-srcWhere images can load from
connect-srcWhere fetch/XHR can connect to
frame-ancestorsWho can embed your page in an iframe

Start with Content-Security-Policy-Report-Only to collect violation reports without breaking anything. Then switch to enforcing mode.

Rule 4: Sanitize HTML When Unavoidable

Sometimes you must render user-provided HTML: a rich text editor, a comment with formatting, or an email preview. Use a sanitizer library that parses the HTML and strips dangerous tags and attributes:

javascriptjavascript
import DOMPurify from "dompurify";
 
const userProvidedHtml = '<p>Nice <b>post</b>!</p><script>alert("xss")</script>';
 
const safeHtml = DOMPurify.sanitize(userProvidedHtml);
// Result: '<p>Nice <b>post</b>!</p>'
 
document.getElementById("comment-body").innerHTML = safeHtml;

DOMPurify removes <script> tags, onerror attributes, javascript: URLs, and everything else that could execute code. It allows safe formatting tags like <b>, <i>, <p>, and <a>.

Never write your own sanitizer. The attack surface is too large. Use a well-maintained library.

DOM-Based XSS

DOM-based XSS happens entirely in the browser. The payload never reaches the server. Common sinks for DOM-based XSS:

javascriptjavascript
// Unsafe DOM sinks
element.innerHTML = userInput;           // HTML injection
element.outerHTML = userInput;           // HTML injection
document.write(userInput);               // HTML injection
eval(userInput);                         // Code execution
new Function(userInput);                 // Code execution
setTimeout(userInput, 1000);             // Code execution (if string)
location.href = userInput;               // Redirect to javascript: URL

Any data from location, document.referrer, document.cookie, window.name, or postMessage is potentially attacker-controlled. Treat it as untrusted.

javascriptjavascript
// Unsafe: reading from location and writing to innerHTML
const hash = location.hash.slice(1);
document.getElementById("output").innerHTML = hash;
 
// Safe: reading from location and writing to textContent
const hash = location.hash.slice(1);
document.getElementById("output").textContent = hash;

Safe Rendering Checklist

Before inserting any dynamic content into the DOM, ask these questions:

  1. Does this data come from outside my code? User input, URL parameters, API responses, cookies, localStorage, postMessage -- all untrusted.
  2. Am I using a safe API? textContent, createElement, setAttribute -- safe. innerHTML, outerHTML, document.write -- unsafe without sanitization.
  3. Am I escaping properly? HTML entities for HTML context. encodeURIComponent() for URL context. Never concatenate strings into executable contexts.
  4. Do I have CSP enabled? A CSP header with script-src 'self' blocks injected scripts even if you miss an escape.

See safe DOM rendering patterns for a deeper dive into each rendering context.

Rune AI

Rune AI

Key Insights

  • XSS happens when untrusted data is rendered as HTML or JavaScript.
  • Escape output: convert < > " ' & to HTML entities.
  • Use textContent and createElement instead of innerHTML.
  • Set a Content Security Policy header to block inline scripts.
  • Sanitize HTML with DOMPurify when you must render user-provided HTML.
RunePowered by Rune AI

Frequently Asked Questions

What are the three types of XSS?

Stored XSS: malicious script is saved on the server and served to every visitor. Reflected XSS: malicious script is part of the request and reflected back in the response. DOM-based XSS: the attack happens entirely in the browser through client-side JavaScript manipulating the DOM unsafely.

Is innerHTML always dangerous?

Yes, if the content comes from user input or untrusted sources. Never set innerHTML with user-provided strings. Use textContent for text, or createElement and setAttribute for HTML structure. If you must set HTML, sanitize it first with a library like DOMPurify.

Does React protect against XSS automatically?

React escapes values in JSX by default, so `<div>{userInput}</div>` is safe. However, dangerouslySetInnerHTML bypasses this protection. Never use dangerouslySetInnerHTML with untrusted content.

Conclusion

XSS prevention comes down to one rule: never treat untrusted data as executable code. Escape HTML entities, use safe DOM methods, set a strict Content Security Policy, and sanitize HTML when you absolutely must render it. Every layer of defense matters.XSS prevention is a habit, not a one-time fix. Escape every untrusted string before it touches HTML. Use textContent and createElement instead of innerHTML. Set a Content Security Policy header. Sanitize with DOMPurify when you must render rich HTML. Never trust input from the URL, the user, or any external source. The goal is not to be clever. It is to be consistent. Pick the safe API every time, and XSS stops being a threat.