React Security Best Practices: XSS Prevention, Auth, and Data Protection

TL;DR

The #1 React security best practice is avoiding dangerouslySetInnerHTML and validating all user input. React escapes content by default, which handles the common case and none of the interesting ones. Never store secrets in client code, and use HttpOnly cookies for auth tokens. About 35 minutes to implement all 8.

"React protects you from XSS by default, but one dangerouslySetInnerHTML can undo it all. Trust React's escaping, distrust user input."

React's Built-in XSS Protection

React escapes values rendered in JSX, which is why most React apps never see a classic XSS bug:

React automatically escapes content
// Safe: React escapes the content
function UserComment({ comment }) {
  return <p>{comment}</p>;
}

// Even if comment contains:
// "<script>alert('XSS')</script>"
// React renders it as text, not HTML

There are three ways out of that protection, and an AI assistant will reach for the first one the moment you ask it to render formatted text. The rest of this post is mostly about those exits.

Best Practice 1: Avoid dangerouslySetInnerHTML 3 min

The prop is named the way it is on purpose. It turns off the escaping that was protecting you, for that one element:

Dangerous: Avoid unless necessary
// DANGEROUS: Renders HTML directly
function UnsafeComponent({ htmlContent }) {
  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

If you must render HTML, sanitize it first:

Safer: Sanitize before rendering
import DOMPurify from 'dompurify';

function SaferHtmlComponent({ htmlContent }) {
  const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a'],
    ALLOWED_ATTR: ['href'],
  });

  return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
}

Better alternative: Use a markdown library like react-markdown instead of rendering raw HTML. It provides structured, safe rendering of formatted content.

Best Practice 2: Validate All User Input 5 min

Client-side validation is a UX feature, not a security control: anyone can skip your form and POST directly to the API. Validate in both places, and treat the server copy as the real one:

Input validation with Zod and React Hook Form
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const userSchema = z.object({
  email: z.string().email('Invalid email'),
  name: z.string()
    .min(2, 'Name too short')
    .max(100, 'Name too long')
    .regex(/^[a-zA-Z\s]+$/, 'Name can only contain letters'),
  website: z.string().url().optional().or(z.literal('')),
});

function ProfileForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(userSchema),
  });

  const onSubmit = async (data) => {
    // Data is validated - safe to send to API
    await updateProfile(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      {/* ... */}
    </form>
  );
}

Best Practice 3: Secure URL Handling 3 min

A URL a user supplied is a string that decides where a click goes, and javascript: is a valid protocol. React won't escape its way out of that one:

Validate URLs before use
function SafeLink({ url, children }) {
  const isValidUrl = (urlString) => {
    try {
      const url = new URL(urlString);
      // Only allow http and https protocols
      return ['http:', 'https:'].includes(url.protocol);
    } catch {
      return false;
    }
  };

  if (!isValidUrl(url)) {
    return <span>{children}</span>; // Render as text if invalid
  }

  return (
    <a
      href={url}
      target="_blank"
      rel="noopener noreferrer"
    >
      {children}
    </a>
  );
}

Watch out for: javascript: URLs can execute code. Never allow user-provided URLs in href without validation. The javascript: protocol bypasses React's XSS protection.

Best Practice 4: Secure Authentication State 5 min

This is the decision that most often gets made by accident, because localStorage.setItem is the first thing that works:

Secure auth token handling
// WRONG: Storing token in localStorage (vulnerable to XSS)
localStorage.setItem('token', authToken);

// BETTER: Use HttpOnly cookies (set by server)
// The cookie is automatically sent with requests
// and cannot be accessed by JavaScript

// For API calls, let the browser handle cookies:
async function fetchUserData() {
  const response = await fetch('/api/user', {
    credentials: 'include', // Send cookies
  });
  return response.json();
}

// If you must use tokens in memory (SPA with separate API):
// Store in memory, not localStorage
const authStore = {
  token: null,
  setToken(token) { this.token = token; },
  getToken() { return this.token; },
  clearToken() { this.token = null; },
};

Best Practice 5: Protect Against CSRF 5 min

Cookies get attached to requests automatically, which is convenient for you and equally convenient for a form on someone else's site. If your backend authenticates with cookies, you need CSRF protection:

CSRF token handling in React
// Get CSRF token from meta tag or cookie
function getCsrfToken() {
  return document.querySelector('meta[name="csrf-token"]')?.content;
}

// Include in API calls
async function secureApiCall(endpoint, data) {
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': getCsrfToken(),
    },
    credentials: 'include',
    body: JSON.stringify(data),
  });
  return response.json();
}

Best Practice 6: Environment Variables 2 min

There's no such thing as a private environment variable in a React build. Anything prefixed VITE_ or REACT_APP_ gets compiled into the JavaScript you ship, and View Source is all it takes:

Environment variable safety
// In React (Create React App, Vite):
// All REACT_APP_ or VITE_ prefixed vars are bundled into the app

// SAFE: Public configuration
const apiUrl = import.meta.env.VITE_API_URL;
const publicKey = import.meta.env.VITE_STRIPE_PUBLIC_KEY;

// NEVER DO THIS: These would be exposed to users
// const apiSecret = import.meta.env.VITE_API_SECRET;  // WRONG!
// const dbPassword = import.meta.env.VITE_DB_PASS;    // WRONG!

// Keep secrets on your backend server, not in React

Best Practice 7: Secure Dependencies 5 min

Your dependency tree is code you shipped without reading. A typical React app pulls in several hundred packages transitively:

Dependency Security Checklist:

  • Run npm audit regularly and fix vulnerabilities
  • Use npm audit --fix for automatic patches
  • Review packages before installing (check downloads, maintenance)
  • Keep dependencies updated, especially security patches
  • Use lock files (package-lock.json) for reproducible builds
  • Consider tools like Snyk or Dependabot for monitoring

Best Practice 8: Secure API Calls 7 min

The trap here is the error path. A caught error stringified straight into the UI can hand a user your stack trace, your database column names, or an internal hostname:

Secure API call patterns
async function fetchData(endpoint) {
  try {
    const response = await fetch(endpoint, {
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
      },
    });

    if (response.status === 401) {
      // Handle unauthorized - redirect to login
      window.location.href = '/login';
      return null;
    }

    if (!response.ok) {
      // Log error but don't expose details to user
      console.error('API error:', response.status);
      throw new Error('Request failed');
    }

    return await response.json();
  } catch (error) {
    // Generic error message for users
    // Log full error for debugging
    console.error('Fetch error:', error);
    throw new Error('Unable to load data. Please try again.');
  }
}

Common React Security Mistakes

MistakeRiskPrevention
Using dangerouslySetInnerHTMLXSS attacksSanitize with DOMPurify or avoid
Storing tokens in localStorageToken theft via XSSUse HttpOnly cookies
Secrets in env varsCredential exposureKeep secrets on backend only
Unsanitized URLs in hrefXSS via javascript: URLsValidate URL protocol
Missing input validationInjection attacksValidate with Zod or similar

Does React prevent XSS automatically?

React escapes content rendered in JSX, preventing most XSS. However, dangerouslySetInnerHTML, javascript: URLs, and some edge cases can still allow XSS. Always validate user input and avoid rendering raw HTML.

Where should I store auth tokens in React?

Prefer HttpOnly cookies set by your server. If you must use tokens in a SPA with a separate API, store them in memory (React state/context) rather than localStorage. Tokens in localStorage are vulnerable to XSS.

Can I put API keys in my React app?

Only publishable/public keys that are designed for client-side use (like Stripe publishable keys). Secret keys must stay on your server. Everything in a React app is visible to users.

How do I handle authentication in React?

Use established libraries like NextAuth, Auth0, or Firebase Auth rather than building custom auth. If using JWTs, validate them on your server for every request, not just on the client.

Further Reading

Put these practices into action with our step-by-step guides.

Verify Your React Security

Scan your React project for security issues and vulnerabilities.

Best Practices

React Security Best Practices: XSS Prevention, Auth, and Data Protection