Command Palette

Search for a command to run...

Hectal
PHASE 21Advanced ~29 min· topic 1 of 6

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.hash and writes it into the page as HTML.
Escaping
Turning special HTML characters such as < into harmless codes such as &lt; 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 innerHTML or eval.
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 (&lt; would show up literally as &lt; on screen).

Comment.jsxwhole filejsx
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.

Know the path from input to screendiagram
Rendering diagram…

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.

Bio.jsxwhole filejsx
// 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.

server.jsxwhole filejsx
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.

terminalwhole filebash
$ 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. 1

    Try to break the comment

    In the first example, change untrusted to "<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, and img elements created stays 0.)

  2. 2

    Fix the unsafe bio

    In the second example, change UnsafeBio so it renders <p className="unsafe">{html}</p> with no ref and no effect. Predict the first log line now. (It disappears, and img inside .unsafe becomes 0.)

  3. 3

    Forget the escape

    In the third example, remove .replace(...) from the safe line. Predict whether the second printed tag now contains a raw </script>. Then put it back.

Code & diagrams

JSX shows untrusted text as text New tab

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

Press Run to see the page.

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: &lt;img src=x onerror="console.log('this would have run')"&gt; Nice post!
img elements created: 0
title attribute: Written by Ravi" onmouseover="console.log(1)
extra attributes: [ 'class', 'title' ]
A ref with innerHTML skips React's protection New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

this would have run
img inside .safe: 0
img inside .unsafe: 1
Server-rendered state can break out of its script tag New tab

`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

Press Run to see the page.

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> tags

Break 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')">.

terminal
$ Console
── what you'll see ──
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)"}')} />.

terminal
$ Console
── what you'll see ──
Expected `onClick` listener to be a function, instead got a value of `string` type.

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 &lt; 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-dom sets text with textContent/text nodes and attributes with setAttribute, so there's no string escaping to get wrong. On the server, react-dom/server escapes &, <, >, " 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 was react.element before). A Symbol can't be produced by JSON.parse, so a JSON response can't pretend to be a React element and inject components or dangerouslySetInnerHTML. That check was added in React 0.14 for exactly this reason.

  • ▸

    The browser ignores <script> elements inserted with innerHTML, which is why real attacks use event-handler attributes (onerror, onload) or javascript: URLs instead. Don't rely on that: sanitise or avoid the sink.

  • ▸

    A Content Security Policy without 'unsafe-inline' blocks inline event-handler attributes like onerror="..." 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. 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 like location.hash and writes it into the page as HTML. React apps are mostly at risk from stored and DOM-based XSS.

  2. 2

    Escaping means turning characters that have a special meaning in HTML into harmless codes: < becomes &lt;, > becomes &gt;, & becomes &amp;, and inside attributes " becomes &quot;. In the browser, React doesn't even need to escape: when you write {text}, react-dom creates a text node (document.createTextNode, or sets textContent), 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. 3

    Attribute values are safe too. title={userName} is set with setAttribute, 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 like Ravi" onmouseover="... stays one harmless title. Props whose names start with on must be functions: React refuses a string onClick (it logs Expected \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. The style prop takes an object of CSS values, never a CSS string.

  4. 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 blocks javascript: URLs in href and friends, but not every bad URL (Topic 21.3). (4) Spreading untrusted objects as props, which can smuggle in dangerouslySetInnerHTML or href (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 use innerHTML inside (some chart, Markdown, rich-text and tooltip libraries).

  5. 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.stringify does 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 as serialize-javascript. Next.js and React Router do this for their own payloads; you must do it when you write such a tag yourself.

  6. 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

01

What is XSS, and what can an attacker do once their script runs in your page?

02

Exactly why is <p>{userText}</p> safe in React, both in the browser and in server rendering?

03

List the ways XSS can still get into a React app.

04

Why is putting JSON.stringify(data) inside a <script> tag dangerous, and how do you fix it?

Practice

01

A component shows a search term from the URL: div.innerHTML = 'Results for ' + q. Rewrite it safely in React.

02

Write a small helper serializeState(obj) that is safe to put inside <script type="application/json">, and show how the client reads it back.

03

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 innerHTML through 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.