When I first got into web development, I kept hearing people mention the OWASP Top 10 like it was common knowledge everyone was just supposed to have. I looked it up and it turned out to be exactly what it sounds like: a "most wanted" list of the ten most critical risks to web applications, maintained by the Open Worldwide Application Security Project A.K.A OWASP, which is a non-profit foundation that works to improve software security worldwide. I started reading through it mostly out of curiosity.
Cross-Site Scripting, commonly abbreviated as XSS, was the one that actually stopped me. XSS is a type of attack where an attacker injects malicious scripts into web pages viewed by other users. The injected script runs in the victim's browser under the same origin as the legitimate site, which means it can access cookies, session tokens, and any other sensitive information the browser holds for that site. Not because it sounded exotic, but because I realized I had no real answer for how I'd stop someone from injecting a script into an app I'd already shipped. I was using React, and I'd absorbed this vague idea that React "handles" security for you. That assumption is exactly what almost got me.
This isn't a "sanitize your inputs" one-liner. I'm walking through exactly where React's built-in protection stops, why that gap matters even outside the browser, and the concrete patterns: sanitization, which means cleaning untrusted input by stripping out dangerous elements before it reaches the page; output encoding, which means converting special characters into safe representations at the point they're rendered; CSP, which is a browser header that restricts what resources a page is allowed to load; and cookie flags, which are extra attributes attached to cookies that limit how browsers handle them. These are the things I now treat as non-negotiable in every React app I ship.
How React Works, and Where It Fails
Here's the part that gave me false confidence: React does escape string variables automatically when you render them in JSX. JSX is React's syntax extension for JavaScript that looks like HTML but gets compiled into regular JavaScript function calls under the hood. If a user types <script>alert(1)</script> into a comment box and you render it as {comment.text}, React converts it to harmless text on the page instead of executing it. That default behavior is real, and it quietly blocks a huge percentage of naive XSS attempts.
The problem is that "React escapes strings in JSX" is a much narrower guarantee than "React prevents XSS." I found three places where that guarantee just doesn't apply, and all three show up constantly in real codebases.
Trap 1: dangerouslySetInnerHTML
The name is honest, but it doesn't stop people from reaching for it anyway, usually to render rich text from a CMS, which stands for Content Management System, a software application that lets users create and manage digital content without needing to touch code directly. Or from a WYSIWYG editor, which is an editing interface where what you see on screen is roughly what you get in the final output, like a mini word processor embedded in your app. Whatever string you hand to dangerouslySetInnerHTML gets dropped straight into the DOM, which is the Document Object Model, the browser's internal tree representation of all the elements on a page, as real HTML, escaping included.
// VULNERABLE: raw HTML from the database, no sanitization
function ArticleBody({ html }) {
return (
<div dangerouslySetInnerHTML={{ __html: html }} />
);
}
If that html value ever originated from user input, a comment, a bio field, a support ticket someone pasted from an "editor," an attacker can embed a <script> or an <img onerror=...> payload and it will execute in every browser that renders the page. The onerror attribute is an event handler that fires when an element fails to load, and attackers abuse it to run JavaScript by pointing the image source to something invalid so the error triggers automatically.
Trap 2: User Input in an href
This one caught me off guard the first time I saw it. Nobody thinks of a link as "executable," but the browser disagrees. An href is the attribute on an anchor tag that specifies the URL the link points to.
// VULNERABLE: href built directly from user-controlled data
function ProfileLink({ website }) {
return <a href={website}>Visit site</a>;
}
// If website = "javascript:alert(document.cookie)"
// clicking the link executes the script — React never escapes this
React escapes text content, not URL schemes. A javascript: href, which is a special URL protocol that tells the browser to execute the JavaScript code that follows it instead of navigating to a page, sails right through JSX untouched, and it fires the moment someone clicks it.
Trap 3: Unsanitized Objects in Props
The subtlest of the three. You're not rendering raw HTML anywhere obvious, but you're passing an object straight from an API response into a component that eventually spreads it onto a DOM element or a third-party widget that isn't as careful as React's own renderer. An API response is the data your server sends back to the frontend after a request, usually in JSON format, which is a lightweight data format that uses key value pairs and arrays.
- A rich-text editor prop that takes a config object and internally uses
innerHTML. - A chart or table library that accepts an HTML string for tooltips or headers.
- Server-rendered fragments passed through props during hydration, which is the process where a server-rendered React page gets "attached" to the client-side React framework so it becomes interactive.
None of these are React's fault exactly. They're the places where you've stepped outside React's rendering path without realizing it.
It's Not Just Websites
The "S" in XSS made me assume this was purely a browser problem, so I almost skipped past it as irrelevant to anything mobile. That assumption was wrong. Hybrid apps built with React Native WebViews, Ionic/Capacitor, or Cordova render HTML content the same way a browser does. A WebView is a component that embeds a browser engine inside a native mobile app, essentially letting you display web content without leaving the app. Capacitor and Cordova are frameworks that let you build mobile apps using web technologies and wrap them in a native shell with a WebView.
If that WebView loads user-generated or remote content without sanitization, the exact same injection techniques apply. On some hybrid setups the WebView also has a bridge back into native device APIs, which is a communication channel that lets JavaScript running inside the WebView call native phone functions like the camera or file system. That makes a successful injection considerably worse than "just" a stolen cookie. Once I understood that, XSS stopped being a "web thing I should know" and became a "any client that renders HTML" thing.
How I Prevent It Now
None of this requires exotic tooling. It requires treating every one of the traps above as a deliberate decision point instead of something you do because it's the fastest way to get a feature working.
Sanitize Before You Ever Touch dangerouslySetInnerHTML
If you genuinely need to render rich HTML, run it through a sanitizer first. I use DOMPurify, which is an open-source library that takes untrusted HTML and removes any dangerous elements, attributes, and scripts while leaving safe formatting like bold and links intact. It works by parsing the HTML into a DOM tree, walking through every node, and stripping anything that isn't on an explicit allow list. The key is to restrict which HTML tags and attributes are permitted so only the formatting you actually need gets through.
import DOMPurify from 'dompurify';
function ArticleBody({ html }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href']
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Validate URL Schemes Before Binding to href
Before putting any user-supplied value into an href or src attribute, parse it with the URL constructor and check that the protocol is one you expect, like http, https, or mailto. The URL constructor is a built-in browser API that parses a string into a structured URL object with properties like protocol, hostname, and pathname. If the protocol is anything else, especially javascript:, do not render it as a link. Render it as plain text instead.
function isSafeUrl(url) {
try {
const parsed = new URL(url, window.location.origin);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
}
function ProfileLink({ website }) {
if (!isSafeUrl(website)) return <span>{website}</span>;
return <a href={website} rel="noopener noreferrer">Visit site</a>;
}
Encode on the way out, not just on the way in. Sanitizing at the input boundary is good practice, but the render boundary is where XSS actually happens. Output encoding means converting special characters like < and > into their HTML entity equivalents, which are < and >, at the point they're written to the DOM. That way the browser treats them as text characters, not as the start of an HTML tag. This is exactly what plain JSX interpolation already does for you, as long as you don't route around it.
Set a Strict Content Security Policy
Sanitization is your first layer. CSP, which stands for Content Security Policy, is the layer that catches what slips through. It is an HTTP response header that tells the browser which resources are allowed to load and execute on a given page. You can restrict where scripts can come from, whether inline scripts are allowed, which domains can embed your page in an iframe, and more. Even if a payload somehow makes it into the DOM, a properly configured CSP tells the browser to refuse inline scripts and untrusted script sources outright.
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
Dropping 'unsafe-inline' from the script-src directive is the single change that does the most work here. The script-src directive is the part of the CSP that controls which scripts the browser is allowed to run. By removing unsafe-inline, you're telling the browser to reject any script that's written directly inside an HTML tag. It means an attacker who does manage to inject a <script> tag still can't get it to run.
Keep Session Tokens Out of Reach with HttpOnly Cookies
This one isn't about stopping XSS. It's about limiting the damage when prevention fails anyway. A session token is a unique string your server issues after a user logs in, which the browser sends back with every subsequent request so the server knows who is making the request. If your session token lives in localStorage, which is a browser storage mechanism that stores key-value pairs persistently across page reloads, or in a regular cookie, a successful injection can read it directly with document.cookie or localStorage.getItem. An HttpOnly cookie is a cookie set with a flag that makes it invisible to JavaScript entirely. The browser still sends it with requests, but no client-side code can access its value.
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 1000 * 60 * 60 * 8
});
You should also set the Secure flag, which ensures the cookie is only sent over HTTPS connections and never over plain HTTP, and the SameSite flag set to strict, which prevents the browser from sending the cookie with requests that originate from a different site. Together these three flags make session token theft dramatically harder even if an XSS vulnerability exists elsewhere.
Production Checklist
XSS prevention isn't a box you check once. But these are the four rules I now hold every React codebase to before I call it production-ready:
- Treat every
dangerouslySetInnerHTMLcall as a review flag: sanitize with DOMPurify or remove it entirely - Validate URL schemes on any user-controlled
hreforsrcbefore binding it - Ship a CSP header without
'unsafe-inline'inscript-src - Store session tokens in
HttpOnly,Secure,SameSitecookies: never inlocalStorage
None of this makes an app unhackable, and I don't treat it that way. XSS was just the first lock I learned to check. CSRF, which stands for Cross-Site Request Forgery and is an attack that tricks an authenticated user into making unintended requests to a site they're already logged into. SQL injection, which is an attack where an attacker inserts malicious SQL statements into input fields to manipulate or extract data from the database. And broken access control, which is when users can access resources or perform actions they shouldn't be allowed to. These are all still sitting on the same list. Security is a process I keep working through, not a milestone I hit once and move past.
