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.
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:
// 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:
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:
function escapeHtml(str) {
const entityMap = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return str.replace(/[&<>"']/g, char => entityMap[char]);
}Now the search page is safe:
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:
Results for: <img src=x onerror="alert('XSS')">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 of | Use | Why it is safe |
|---|---|---|
el.innerHTML = str | el.textContent = str | Treats content as text, never HTML |
el.innerHTML = str | el.innerText = str | Same 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.write | It has no safe use with untrusted input |
href="javascript:..." | Use # or button with event listener | javascript: URLs are executable |
Example: building a list from untrusted data the safe way:
// 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:
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:
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:
| Directive | What it controls |
|---|---|
script-src | Where JavaScript can load from |
style-src | Where CSS can load from |
img-src | Where images can load from |
connect-src | Where fetch/XHR can connect to |
frame-ancestors | Who 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:
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:
// 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: URLAny data from location, document.referrer, document.cookie, window.name, or postMessage is potentially attacker-controlled. Treat it as untrusted.
// 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:
- Does this data come from outside my code? User input, URL parameters, API responses, cookies, localStorage, postMessage -- all untrusted.
- Am I using a safe API?
textContent,createElement,setAttribute-- safe.innerHTML,outerHTML,document.write-- unsafe without sanitization. - Am I escaping properly? HTML entities for HTML context.
encodeURIComponent()for URL context. Never concatenate strings into executable contexts. - 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
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.
Frequently Asked Questions
What are the three types of XSS?
Is innerHTML always dangerous?
Does React protect against XSS automatically?
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.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.