EthicsComputerCourses
Join training +91 90532 10052

Security in React Apps

React · Lesson 36 of 41 Advanced 8 min read Updated

React protects you from the most common web attack by default, which leads many developers to believe React apps are automatically secure. They are not. A few React features bypass the protection, the browser can read every line of your front-end code, and your app depends on hundreds of npm packages written by strangers.

In this lesson you will see exactly what React protects against, the escape hatches that remove that protection, how to show user-written rich text safely, how to handle links, secrets and login tokens, and how to keep dependencies patched. Everything here is defensive: run the examples only in this editor or your own projects. For the theory of the attack itself, see Cross-Site Scripting (XSS).

What React escapes for you

Cross-site scripting (XSS) happens when text from a user, such as a product review, ends up being run as code in another user's browser. If an attacker's script runs on your page, it can read anything the page can: personal data, tokens, form inputs.

When you render a value with braces, {review}, React inserts it as text. Any < and > characters show up literally and nothing runs. The escape hatch dangerouslySetInnerHTML inserts a string as HTML, exactly like the DOM's innerHTML. Compare both with the same harmless "attack" in this example:

React JSXTry it yourself
import { createRoot } from "react-dom/client";

// A review typed by a user. The onerror attribute is code that runs if the image fails to load.
const review = `Great tailoring! <img src="x" onerror="console.log('Attacker code ran in your page!')">`;

function SafeReview() {
  return <p>Safe: {review}</p>;
}

function UnsafeReview() {
  return <p>Unsafe: <span dangerouslySetInnerHTML={{ __html: review }} /></p>;
}

createRoot(document.getElementById("root")).render(
  <>
    <SafeReview />
    <UnsafeReview />
  </>
);
Output
Attacker code ran in your page!

The first paragraph shows the tag as plain text. The second turns it into a real broken image whose onerror code runs. A real attacker would not log a message; they would send your users' data to their own server. The name dangerouslySetInnerHTML is a deliberate warning, and its argument must be an object with an __html key so that nobody uses it by accident.

Showing rich text safely

Sometimes you do need formatted user content: a blog editor, a product description with bold text, an email preview. You have two safe choices.

Option 1: never build HTML strings. Convert the user's text into React elements, so everything still goes through React's escaping. This tiny formatter turns *word* into bold text and nothing else:

React JSXTry it yourself
import { createRoot } from "react-dom/client";

function FormattedText({ text }) {
  const parts = text.split(/(\*[^*]+\*)/); // keep the *bold* pieces as separate parts
  return (
    <p>
      {parts.map((part, i) =>
        part.startsWith("*") && part.endsWith("*") ? <strong key={i}>{part.slice(1, -1)}</strong> : part
      )}
    </p>
  );
}

createRoot(document.getElementById("root")).render(
  <>
    <FormattedText text="Diwali sale: *flat 20% off* on all sarees" />
    <FormattedText text={`Nice *shop* <img src=x onerror="console.log('blocked?')">`} />
  </>
);

The second line prints its <img ...> as visible text, and nothing appears in the console, because every piece is rendered as text or as a <strong> element that React created. Markdown libraries for React work the same way, building elements instead of HTML strings.

Option 2: sanitise, then insert. If the content must be HTML (for example, it comes from a rich-text editor), clean it with a sanitiser such as DOMPurify, which removes scripts, event handler attributes and dangerous links while keeping safe formatting:

React JSX
import DOMPurify from "dompurify";

const dirty = '<b>Great sir!</b> <img src="x" onerror="console.log(1)"> <a href="javascript:alert(1)">link</a><script>console.log(2)</script>';
console.log(DOMPurify.sanitize(dirty));

function ProductDescription({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}
Output
<b>Great sir!</b> <img src="x"> <a>link</a>

Sanitise right before inserting, in the browser, every time. Sanitising once when the content was saved is not enough: the database might be changed later, or another app might write to it. Never write your own HTML sanitiser with regular expressions; attackers know far more tricks than any regex can cover.

Unsafe URLs

Escaping text does not help when the user's value becomes a URL. A profile page that renders <a href={user.website}> lets a user set their website to javascript:..., which runs code when someone clicks the link. React 19 blocks javascript: URLs in href and src: it replaces them with a URL that only throws the error "React has blocked a javascript: URL as a security precaution." That is a safety net, not a design. Check URLs yourself and allow only the schemes you expect:

React JSX
function safeUrl(value) {
  try {
    const url = new URL(value);
    return ["https:", "http:", "mailto:"].includes(url.protocol) ? url.href : null;
  } catch {
    return null; // not a valid absolute URL
  }
}

function Website({ value }) {
  const href = safeUrl(value);
  return href ? <a href={href} rel="noopener noreferrer" target="_blank">{href}</a> : <span>No website</span>;
}

An allow-list ("only these schemes") is safer than a block-list ("not javascript:"), because there are more dangerous schemes and spellings than you can list.

Secrets, tokens and permissions

Three rules cover most mistakes in this area:

  • No secrets in front-end code. Everything in your bundle, including every VITE_ environment variable, is downloaded by the browser and readable in DevTools. Payment gateway secret keys, SMS API keys and database passwords belong on your server. The server calls the paid API, and your React app calls your server.
  • Store login tokens carefully. A token in localStorage can be read by any script on the page, so a single XSS bug lets an attacker steal every logged-in session. Prefer a session cookie set by the server with the HttpOnly flag (JavaScript cannot read it), Secure (sent only over HTTPS) and SameSite=Lax or Strict (helps against cross-site request forgery). The browser then sends it automatically and your React code never touches it.
  • Hiding is not protecting. Removing the "Delete user" button for non-admins is good UI, but anyone can still call the API directly. The server must check permissions on every request.

Dependencies and patching

A typical React project installs hundreds of packages, and your app is only as safe as the weakest one. Run npm audit to list known vulnerabilities, commit package-lock.json so everyone installs the same versions, and before adding a package check that its name is spelled correctly (fake packages with look-alike names exist) and that it is actively maintained.

Keep React and your framework updated, too. In December 2025 the React team disclosed CVE-2025-55182, a critical remote code execution flaw (CVSS score 10.0) in the packages that implement React Server Components, affecting frameworks such as Next.js and React Router. It was fixed in versions 19.0.1, 19.1.2 and 19.2.1 of those packages, and apps had to upgrade them (and their framework) to be safe. Client-only apps that do not use a server were not affected, but the lesson applies to everyone: follow the React blog and your framework's security announcements, and upgrade promptly.

Common mistakes

  • dangerouslySetInnerHTML with user content. Sanitise it with DOMPurify first, or render elements instead.
  • Building HTML strings and inserting them. Template strings with user data inside HTML are XSS waiting to happen.
  • Rendering user URLs directly. Validate the scheme with new URL() and an allow-list.
  • API secrets in VITE_ variables. Anyone can read them in the browser.
  • Tokens in localStorage. One XSS bug exposes every session; prefer HttpOnly cookies.
  • Security checks only in the UI. The server must enforce permissions.
  • Never updating. Old versions of React, frameworks and libraries keep their known holes.

Key points

  • JSX escapes values in braces, which prevents most XSS automatically.
  • dangerouslySetInnerHTML inserts raw HTML; use it only with trusted or sanitised content.
  • Prefer rendering user formatting as React elements; otherwise sanitise HTML with DOMPurify right before use.
  • Validate user-supplied URLs with new URL() and allow only https:, http: or mailto:.
  • Everything in the bundle is public: keep secrets on the server and enforce permissions there.
  • Store sessions in HttpOnly, Secure, SameSite cookies, run npm audit and keep React patched.

Exercise

Write a SocialLinks component that receives an array of user-entered links such as ["https://instagram.com/meera.designs", "javascript:alert(1)", "not a url", "mailto:meera@example.com"]. Show only the safe ones as links (allow https: and mailto: only), and log a warning line for each rejected value. Render user text only with braces, never with dangerouslySetInnerHTML.

Show solution
React JSX
import { createRoot } from "react-dom/client";

const ALLOWED = ["https:", "mailto:"];

function toSafeUrl(value) {
  try {
    const url = new URL(value);
    return ALLOWED.includes(url.protocol) ? url.href : null;
  } catch {
    return null;
  }
}

function SocialLinks({ links }) {
  const safe = [];
  for (const value of links) {
    const href = toSafeUrl(value);
    if (href) safe.push(href);
    else console.log(`Rejected link: ${value}`);
  }
  return (
    <ul>
      {safe.map((href) => (
        <li key={href}><a href={href} target="_blank" rel="noopener noreferrer">{href}</a></li>
      ))}
    </ul>
  );
}

createRoot(document.getElementById("root")).render(
  <SocialLinks links={["https://instagram.com/meera.designs", "javascript:alert(1)", "not a url", "mailto:meera@example.com"]} />
);

new URL() throws for text that is not a URL, and the protocol check rejects javascript:. Only values that pass both checks become href attributes. In a larger app, filter the list with useMemo or before rendering instead of logging during render.

Quick Quiz

1. A user's comment contains <script>...</script> and you render it as {comment}. What happens?

2. When is dangerouslySetInnerHTML acceptable?

3. Why is a block-list check such as !url.startsWith("javascript:") weak?

4. Where should a payment gateway's secret API key be stored?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call