Keeping Pages Safe: XSS and innerHTML

When a page puts someone else's text into innerHTML, that text can become code that runs in every visitor's browser, so you show it with textContent and createElement instead, and you keep secrets off the front end altogether.

Journeyman · JavaScript · Web Development · Updated

After this lesson you can

  • Explain how cross-site scripting (XSS) works and why innerHTML with someone else's text is dangerous
  • Show someone else's text safely with textContent and createElement instead of innerHTML
  • Explain why secrets such as API keys never belong in front-end code

Key takeaways

  • Cross-site scripting (XSS) is when text from outside your code becomes code on your page and runs in your visitors' browsers.
  • innerHTML reads its text as HTML, so a book title made of HTML becomes real HTML, and code hidden in it can run.
  • textContent always shows text as text. Build anything with more structure with createElement, and fill each part with textContent.
  • Treat all text from outside your code as untrusted, whether it comes from a form, the address bar, localStorage or an API.
  • Anyone can read everything the browser downloads, so secrets such as private API keys belong on a server, never in front-end code.

Most pages show text that somebody else wrote: product reviews, comments, usernames, a chat, a seller's listing. Inkwell & Oak Books lets used-book sellers add their own books to the catalog, and one seller typed a strange title: <img src=x onerror=alert(1)>. Whether that shows up as a harmless line of odd text or runs as code on every visitor's screen depends on one choice in your JavaScript. This mistake is one of the most common security bugs on the web, and it gets found in class projects and in big companies' sites alike. This lesson shows you how it happens, using a harmless example, and the habits that make your pages safe.

Text that turns into code

A sign shop prints whatever you write on the order form. A careful shop prints the letters exactly as written. A careless one also follows anything on the form that looks like an instruction. A page has the same two choices when it shows text, and innerHTML is the careless one: it reads the text you give it as HTML and builds whatever elements it describes.

const book = { title: '<img src=x onerror=alert(1)>', author: 'Unknown Seller' };
const list = document.querySelector('#results');

list.innerHTML += `<li>${book.title} by ${book.author}</li>`;

The browser reads the title as a real <img> element. Its picture address, x, doesn't exist, so the picture fails to load, and the browser runs the code in its onerror attribute: alert(1). A little box with a 1 in it pops up. That's all it does here, which is why it's the classic test people use to show that a page is open to this attack, and why it's safe to try on your own page.

The bookstore's results list with a browser alert box showing the number 1 in front of it: a book title has run as code

What it's called, and why it matters: XSS

Getting your own code to run on someone else's web page is called cross-site scripting, or XSS for short (read "X S S"). "Cross-site" because the script comes from outside the site, but it runs as if the site had written it, with everything the site's own code can do.

That's what makes it serious. Instead of showing a harmless box, the code hidden in a title could change what the page shows, put a fake sign-in form on it, read what the visitor types, or send requests as the visitor while they're signed in. And it runs for every visitor who sees that title, not only the person who typed it.

You might hear that a <script> element put in with innerHTML doesn't run. That's true, and it's why the attack uses an <img> with an onerror attribute instead, which does run. Don't try to guess which pieces of HTML are dangerous; the safe habit is not to read someone else's text as HTML at all.

The safe way: textContent

The careful sign shop is a label maker: it prints exactly the letters you type, and a < comes out as a <. That's textContent. It sets an element's text as plain text and never reads it as HTML, so there's nothing for the browser to build and nothing to run.

const book = { title: '<img src=x onerror=alert(1)>', author: 'Unknown Seller' };
const list = document.querySelector('#results');

const item = document.createElement('li');
item.textContent = `${book.title} by ${book.author}`;
list.append(item);

The page shows the title's characters as they are: <img src=x onerror=alert(1)> by Unknown Seller. A strange title, but only text. document.createElement('li') makes a new, empty element, and append adds it to the list (Manipulating the DOM Structure).

Try it. Save this page as xss.html and open it in your browser. The alert box pops up once, from the first list, and the second list shows the same title as text.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Two ways to show a title</title>
  </head>
  <body>
    <h1>Two ways to show a title</h1>
    <h2>With innerHTML</h2>
    <ul id="unsafe"></ul>
    <h2>With textContent</h2>
    <ul id="safe"></ul>
    <script>
      const title = '<img src=x onerror=alert(1)>';
      document.querySelector('#unsafe').innerHTML = `<li>${title}</li>`;
      const item = document.createElement('li');
      item.textContent = title;
      document.querySelector('#safe').append(item);
    </script>
  </body>
</html>

More than one line: createElement for each part

A real search result has parts: the title in bold, the author, the price. It's tempting to write that as one template literal and hand it to innerHTML, because it's short. Build it from elements instead, with createElement for each part and textContent for each piece of text:

function makeResult(book) {
  const item = document.createElement('li');
  item.className = 'book';

  const title = document.createElement('strong');
  title.textContent = book.title;

  const author = document.createElement('span');
  author.textContent = `by ${book.author}`;

  item.append(title, author);
  return item;
}

It's longer, and it's safe whatever the title holds. className sets the element's class attribute, so your CSS still finds it. append takes more than one element and adds them in order.

The rule of thumb: if any part of the text came from outside your code, use textContent. innerHTML is only for HTML you wrote yourself, with nothing from a person, a server or the address bar mixed in. To empty a list, use list.replaceChildren() rather than list.innerHTML = '', and you rarely need innerHTML at all.

Never trust input

"Someone else's text" comes from more places than a form:

  • What people type: search boxes, reviews, usernames, a seller's book title.
  • The address bar: a link such as books.html?q=harbor puts harbor in the page's address, and anyone can send a friend a link with HTML in it instead.
  • localStorage: anyone can change it in the developer tools.
  • An API's answers: they hold data that other people typed.

Show all of it with textContent. Here's the search word from the address, shown safely:

const query = new URLSearchParams(location.search).get('q') ?? '';
const heading = document.querySelector('h2');

heading.textContent = `Results for "${query}"`;

location.search is the part of the address after the ?, and URLSearchParams reads the value named q from it (null when there's none, which ?? turns into '').

Checking a form in the browser is a kindness to the person filling it in, not a lock: anyone can switch the checks off in the developer tools, or send their data to the server without using your page at all. That's why a server checks everything again when it arrives (in your textbook: server-side validation), and why your page treats what comes back as text too.

Secrets never go in front-end code

Writing the code for your front door on a postcard means the mail carrier, and everyone else who handles it, can read it. Front-end code is a postcard. Every file the browser downloads, the HTML, the CSS and every line of JavaScript, can be read by anyone who opens the developer tools, and that includes the files a build makes: Vite packs your code, and even the bookstore's whole 500-book catalog, into one JavaScript file that every visitor downloads.

// Don't do this: every visitor can read this line in the developer tools.
const PAYMENT_KEY = 'paste-your-secret-key-here';

An API key is a password that a program uses to call a service, such as a payment company or an AI model. A private key in front-end code is a key anyone can copy and spend your money with. Instead:

  • A key that must stay secret lives on a server you control. Your page asks your server, and your server calls the service with the key. That server is a small Node.js program, the kind Where to Go Next points to.
  • Some keys are made to be public. A map or a payment service may give you a "publishable" key, or one that only works from your site's address. Its documentation says so; if it doesn't, treat the key as secret.
  • Never put a secret in a public repository. Programs scan GitHub for keys all day. If one slips out, cancel it in the service's settings (that's called revoking it) and make a new one: deleting the file isn't enough, because the repository's history still has it.

Check yourself

2 questions to try on the lesson page.

So what

A page that shows other people's text with textContent can't be turned against its own visitors, and a page with no secrets in it has nothing to steal. Those two habits protect every site you'll build, from a class project to a store with thousands of customers.

Key terms

Cheat sheets

Related