Topic 21.1
How React Protects You from XSS
In one line
XSS happens when text from someone else is treated as code in your page. JSX escapes every string you put in text or attributes, so it always shows as text, but refs with innerHTML, dangerouslySetInnerHTML, unsafe URLs, spread props and server-rendered state can still let it in.
Think of it like this
A school notice board where anyone may pin a note. A careful teacher reads every note out loud exactly as written: if a note says "Everyone go home now!", the teacher reads those words, and nobody leaves. A careless teacher follows whatever the notes say. XSS is the careless teacher: the page treats a visitor's note as instructions. JSX is the careful teacher: whatever text you give it is shown as text, never obeyed.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- XSS (cross-site scripting)
- When an attacker's JavaScript runs inside your page in another user's browser.
- Stored XSS
- The harmful text is saved (in a comment, a profile) and runs for everyone who views it.
- Reflected XSS
- The harmful text comes from the request (often the URL) and is shown straight back.
- DOM-based XSS
- Browser code reads something like
location.hashand writes it into the page as HTML. - Escaping
- Turning special HTML characters such as
<into harmless codes such as<so they show as text. - Text node
- A piece of plain text in the DOM. The browser never treats it as HTML.
- Sink
- A place in code where a string can become HTML or code, such as
innerHTMLoreval. - Untrusted input
- Any data you didn't write yourself: user input, URLs, API responses, database content, third-party data.
Step by step
01Put user text in JSX braces
The default way to show data is also the safe one. {comment.text} becomes a text node. You don't need to escape anything yourself, and you should not escape it again (< would show up literally as < on screen).
function Comment({ comment }) {
return (
<article>
<h3>{comment.author}</h3> {/* text node: safe */}
<p>{comment.text}</p> {/* text node: safe */}
<time title={comment.date}>{comment.ago}</time> {/* attribute: safe */}
</article>
);
}02Know the path from input to screen
For each piece of untrusted data, follow it until it reaches the DOM. If every path ends in JSX text or a normal attribute, React has you covered. If a path ends in a sink, that sink needs a sanitiser, a validator, or a rewrite.
03Don't reach around React with refs
A ref gives you the real DOM node (Topic 8.2). Writing innerHTML on it skips everything React does. If you only need text, set textContent or, better, render the text with JSX.
// Unsafe: parses the string as HTML
useEffect(() => { ref.current.innerHTML = bio; }, [bio]);
// Safe: but you rarely need a ref at all
return <p>{bio}</p>;04Escape JSON you put in a script tag
When server code writes data into the HTML page for the client to read, escape < so the data can't close the tag. Read it back with JSON.parse(document.getElementById("state").textContent), never with eval.
const json = JSON.stringify(initialState).replace(/</g, "\\u003c");
<script
id="state"
type="application/json"
dangerouslySetInnerHTML={{ __html: json }}
/>05Search for the sinks
Make it a habit before every release, and add lint rules so new sinks get noticed in review. eslint-plugin-react has react/no-danger (flags dangerouslySetInnerHTML), and ESLint core has no-eval and no-implied-eval.
$ grep -rnE "dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write|new Function|eval\(" src/
src/components/Markdown.jsx:12: return <div dangerouslySetInnerHTML={{ __html: html }} />;
src/legacy/tooltip.js:40: tip.innerHTML = text;Try it yourself
- 1
Try to break the comment
In the first example, change
untrustedto"<b>bold?</b> <script>console.log('hi')</script>". Predict what the page shows and what the logs say before you run it. (The tags show as text, andimg elements createdstays 0.) - 2
Fix the unsafe bio
In the second example, change
UnsafeBioso it renders<p className="unsafe">{html}</p>with no ref and no effect. Predict the first log line now. (It disappears, andimg inside .unsafebecomes 0.) - 3
Forget the escape
In the third example, remove
.replace(...)from thesafeline. Predict whether the second printed tag now contains a raw</script>. Then put it back.
Code & diagrams
The comment appears on screen with its angle brackets, exactly as typed. No `img` element was created, so its `onerror` could never run. The quote in the author's name stayed inside the `title` value instead of starting a new `onmouseover` attribute.
Preview
Console
Nothing logged yet.Expected console output
what the user sees: <img src=x onerror="console.log('this would have run')"> Nice post!
how it sits in the HTML: <img src=x onerror="console.log('this would have run')"> Nice post!
img elements created: 0
title attribute: Written by Ravi" onmouseover="console.log(1)
extra attributes: [ 'class', 'title' ]The first line, `this would have run`, is the proof: the browser parsed the string, created an `img`, failed to load `x` and ran the `onerror` attribute as code. A real attacker would put something harmful there. The `textContent` version shows the same string as plain text.
Preview
Console
Nothing logged yet.Expected console output
this would have run
img inside .safe: 0
img inside .unsafe: 1`renderToStaticMarkup` builds the HTML a server would send. In the first line the browser's HTML parser would end the script at the user's `</script>`, and whatever the user typed next would be parsed as page HTML. The second line can't be closed early, and `JSON.parse` still gives back the original text.
Preview
Console
Nothing logged yet.Expected console output
<script id="state" type="application/json">{"user":"asha","bio":"I love </script> tags"}</script>
<script id="state" type="application/json">{"user":"asha","bio":"I love \u003c/script> tags"}</script>
after JSON.parse: I love </script> tagsBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Write user text with innerHTML
Render a user's bio with useEffect(() => { ref.current.innerHTML = bio; }) instead of <p>{bio}</p>, and save a bio of <img src=x onerror="console.log('this would have run')">.
Break #2
Pass a string as an event handler
Load props from JSON and spread them: <div {...JSON.parse('{"onClick":"console.log(1)"}')} />.
Myth vs fact
Myth
React apps can't have XSS.
Fact
JSX text and attributes are safe, but dangerouslySetInnerHTML, refs with innerHTML, unsafe URLs, spread props, eval and third-party libraries can all reintroduce it.
Myth
I should escape user input before putting it in JSX.
Fact
React already does it. Escaping again shows < on screen. Escape (or sanitise) only for sinks React doesn't handle.
Myth
Validating input on the way in is enough.
Fact
Data reaches your page from many places (old records, other apps, imports). Encode on output, where you know the context (text, attribute, URL, script). Input validation is a second layer.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
On the client,
react-domsets text withtextContent/text nodes and attributes withsetAttribute, so there's no string escaping to get wrong. On the server,react-dom/serverescapes&,<,>,"and'in text and attribute values (the HTML you can see in the third example). Hydration then reuses those nodes (Topic 20.6). - ▸
React elements are plain objects with a
$$typeof: Symbol.for("react.transitional.element")field in React 19 (it wasreact.elementbefore). ASymbolcan't be produced byJSON.parse, so a JSON response can't pretend to be a React element and inject components ordangerouslySetInnerHTML. That check was added in React 0.14 for exactly this reason. - ▸
The browser ignores
<script>elements inserted withinnerHTML, which is why real attacks use event-handler attributes (onerror,onload) orjavascript:URLs instead. Don't rely on that: sanitise or avoid the sink. - ▸
A Content Security Policy without
'unsafe-inline'blocks inline event-handler attributes likeonerror="..."entirely, so even a missed sink often can't run code. React doesn't need inline handlers (it attaches listeners from JavaScript), so React apps can use a strict CSP (Topic 21.6).
Remember this
- 1
XSS (cross-site scripting) is when an attacker gets their own JavaScript to run inside your page, in another user's browser. That script runs with your site's powers: it can read what the user sees, call your API as that user, change the page (a fake login box) or read tokens stored in
localStorage(Topic 21.4). There are three kinds. Stored XSS: the bad text is saved in your database (a comment, a profile name) and shown to everyone who views it. Reflected XSS: the bad text comes from the URL (?q=...) and is shown back in the response. DOM-based XSS: browser code takes something likelocation.hashand writes it into the page as HTML. React apps are mostly at risk from stored and DOM-based XSS. - 2
Escaping means turning characters that have a special meaning in HTML into harmless codes:
<becomes<,>becomes>,&becomes&, and inside attributes"becomes". In the browser, React doesn't even need to escape: when you write{text},react-domcreates a text node (document.createTextNode, or setstextContent), and the browser never parses a text node as HTML. When React renders on the server (renderToString, Next.js), it escapes those characters in the HTML it writes. Either way,<img onerror=...>typed by a user shows up as those exact characters on the screen. - 3
Attribute values are safe too.
title={userName}is set withsetAttribute, which stores the whole string as the value. A quote in the value can't close the attribute and start a new one, so a name likeRavi" onmouseover="...stays one harmless title. Props whose names start withonmust be functions: React refuses a stringonClick(it logsExpected \onClick\listener to be a function, instead got a value of \string\type.and adds no handler), so you can't turn text into an event handler by accident. Thestyleprop takes an object of CSS values, never a CSS string. - 4
What React does not protect, and where XSS still gets in: (1)
dangerouslySetInnerHTML, which writes raw HTML on purpose (Topic 21.2). (2) Refs and direct DOM code:ref.current.innerHTML = text,insertAdjacentHTML,document.write. React can't see what you do with the real DOM node. (3) URLs: React 19 blocksjavascript:URLs inhrefand friends, but not every bad URL (Topic 21.3). (4) Spreading untrusted objects as props, which can smuggle indangerouslySetInnerHTMLorhref(Topic 21.3). (5) Server-rendered state: data put inside a<script>tag as JSON can break out of the tag. (6) Code that turns strings into code:eval,new Function,setTimeout("string"). (7) Third-party libraries that useinnerHTMLinside (some chart, Markdown, rich-text and tooltip libraries). - 5
The server-rendered state trap deserves a closer look because it hides in framework code. A server often puts initial data into the page:
<script type="application/json">{...}</script>.JSON.stringifydoes not escape<, so a user's bio containing</script>ends the script tag early and everything after it is parsed as HTML. The fix is to escape<as\u003c(JSON.parse turns it back into<), or use a library such asserialize-javascript. Next.js and React Router do this for their own payloads; you must do it when you write such a tag yourself. - 6
How to verify you're safe: search the code for the sinks (
dangerouslySetInnerHTML,innerHTML,outerHTML,insertAdjacentHTML,document.write,eval,new Function,href={,src={,{...props}from data). For each one, trace where its value comes from. Test with a harmless marker string such as<b>marker</b>or an<img src=x onerror=...>that only logs: if the page shows the text, you're safe; if it renders bold or logs, you have a sink. Add a Content Security Policy as a second wall (Topic 21.6). The JavaScript course (/javascript) covers the DOM APIs mentioned here.
Explain it without notes
What is XSS, and what can an attacker do once their script runs in your page?
Exactly why is <p>{userText}</p> safe in React, both in the browser and in server rendering?
List the ways XSS can still get into a React app.
Why is putting JSON.stringify(data) inside a <script> tag dangerous, and how do you fix it?
Practice
A component shows a search term from the URL: div.innerHTML = 'Results for ' + q. Rewrite it safely in React.
Write a small helper serializeState(obj) that is safe to put inside <script type="application/json">, and show how the client reads it back.
Write a component that shows a user's name in bold inside a sentence ("Welcome back, Asha!") without using any HTML strings.
Trade-offs
- ↔
JSX's safety comes from refusing to treat strings as HTML. When you genuinely need rich content from users (blog posts, CMS pages), you must take on the cost of a sanitiser and keep it updated (Topic 21.2), or store content in a structured form (Markdown, JSON blocks) and render it with components.
- ↔
A strict CSP adds a strong second wall against missed sinks, but it costs setup work and can break third-party scripts and CSS-in-JS libraries that inject inline styles (Topic 21.6).
Done when you can
I can explain stored, reflected and DOM-based XSS in plain words.
I can explain why JSX text and attributes are safe in the browser and in server rendering.
I can list the sinks where XSS still gets into a React app.
I render text with JSX instead of writing
innerHTMLthrough a ref.I escape
<when I put JSON inside a script tag.I can search a codebase for sinks and test them with a harmless marker string.