Web Security

React Security Checklist: XSS, dangerouslySetInnerHTML, and Dependency Audits

Secure React applications by avoiding dangerouslySetInnerHTML pitfalls, using DOMPurify, implementing secure routing, preventing env var exposure, and running npm audit.

March 9, 20266 min readShipSafer Team

React Security Checklist: XSS, dangerouslySetInnerHTML, and Dependency Audits

React's component model and JSX auto-escaping make it inherently safer than raw DOM manipulation, but several React-specific patterns introduce vulnerabilities that developers regularly overlook. This checklist covers every major React security concern with concrete examples.

XSS and JSX Auto-Escaping

React escapes all values rendered through JSX expressions by default. This is your first line of defense:

// Safe — React escapes the string
const userInput = '<script>alert("xss")</script>';
return <div>{userInput}</div>;
// Renders as: &lt;script&gt;alert("xss")&lt;/script&gt;

// Safe — JSX attributes are also escaped
return <img src={userProvidedUrl} alt={userProvidedAlt} />;

The only way to bypass this protection is dangerouslySetInnerHTML.

The dangerouslySetInnerHTML Problem

The name is a warning, not a suggestion. dangerouslySetInnerHTML disables React's XSS protection and injects raw HTML into the DOM:

// DANGEROUS — direct XSS if content contains <script> tags
function UserComment({ content }: { content: string }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

If you must render HTML (rich text editors, markdown output, email previews), sanitize with DOMPurify before injecting:

npm install dompurify
npm install @types/dompurify  # for TypeScript
import DOMPurify from 'dompurify';

const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'];
const ALLOWED_ATTR = ['href', 'rel', 'target'];

function SafeHtml({ content }: { content: string }) {
  const clean = DOMPurify.sanitize(content, {
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    FORCE_BODY: true,
  });

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

For server-side rendering (Next.js, Remix), use isomorphic-dompurify:

npm install isomorphic-dompurify
import DOMPurify from 'isomorphic-dompurify';

const clean = DOMPurify.sanitize(serverRenderedHtml);

URL Injection: The href and src Vulnerability

React does not sanitize URL attributes. javascript: URIs in href execute JavaScript:

// DANGEROUS — if userUrl is "javascript:alert(1)"
function UserLink({ href, label }: { href: string; label: string }) {
  return <a href={href}>{label}</a>;  // XSS
}

// Safe — validate URL scheme before rendering
function SafeLink({ href, label }: { href: string; label: string }) {
  const safeHref = (() => {
    try {
      const url = new URL(href);
      return ['http:', 'https:'].includes(url.protocol) ? href : '#';
    } catch {
      return '#';
    }
  })();

  return (
    <a href={safeHref} rel="noopener noreferrer">
      {label}
    </a>
  );
}

Always add rel="noopener noreferrer" to links that open in a new tab — this prevents the opened page from accessing window.opener.

Environment Variable Exposure

Build tools like Create React App, Vite, and Next.js support environment variables in the client bundle. The danger: any variable prefixed with REACT_APP_ (CRA), VITE_ (Vite), or NEXT_PUBLIC_ (Next.js) is embedded in the JavaScript bundle and visible to everyone.

# .env — these are DANGEROUS if they contain secrets
REACT_APP_API_KEY=sk-1234abcd        # Exposed in bundle
VITE_STRIPE_SECRET=sk_live_...       # Exposed in bundle — NEVER DO THIS
NEXT_PUBLIC_DATABASE_PASSWORD=...    # Exposed in bundle — NEVER DO THIS

# Safe — these are server-only (Next.js, SSR frameworks)
DATABASE_URL=mongodb://...
JWT_SECRET=supersecret
STRIPE_SECRET_KEY=sk_live_...

Audit your bundle for accidentally exposed secrets:

# Build and search the output for sensitive patterns
npm run build
grep -r "sk_live\|secret\|password\|private_key" dist/

Use @next/bundle-analyzer or vite-bundle-visualizer to inspect bundle contents.

Secure Routing and Protected Routes

Implement route guards that check authentication state before rendering protected content:

'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';

function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { user, isLoading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!isLoading && !user) {
      router.replace('/login');
    }
  }, [user, isLoading, router]);

  if (isLoading) return <LoadingSpinner />;
  if (!user) return null;

  return <>{children}</>;
}

Client-side route guards are a UX convenience, not a security control. The real enforcement must happen on the server — in middleware, server actions, or API handlers. Never rely solely on client-side checks.

The eval() and Function Constructor Risk

Avoid eval(), new Function(), and setTimeout(string) — they execute arbitrary code:

// DANGEROUS
eval(userInput);
new Function('return ' + userInput)();
setTimeout(userInput, 1000);

// Also dangerous in template literals passed to eval
const expression = `${userInput} + 2`;
eval(expression);

These patterns are rarely necessary. If you're evaluating formulas or expressions, use a safe math parser library instead of eval.

PostMessage Security

If your app communicates with iframes or other windows via postMessage, always validate the origin:

useEffect(() => {
  const handleMessage = (event: MessageEvent) => {
    // ALWAYS validate origin
    if (event.origin !== 'https://trusted-domain.com') {
      return;
    }

    // ALWAYS validate message structure
    if (typeof event.data !== 'object' || event.data.type !== 'UPDATE_PRICE') {
      return;
    }

    handlePriceUpdate(event.data.payload);
  };

  window.addEventListener('message', handleMessage);
  return () => window.removeEventListener('message', handleMessage);
}, []);

Dependency Auditing

React applications typically have hundreds of transitive dependencies. Vulnerabilities in any of them affect your app.

# Built-in npm audit
npm audit
npm audit --audit-level=high   # Only fail on high/critical

# Fix automatically (patch-level only)
npm audit fix

# More comprehensive: Snyk
npx snyk test
npx snyk monitor

Add to CI:

# .github/workflows/security.yml
- name: Dependency audit
  run: npm audit --audit-level=high

Check for abandoned or low-quality packages:

# npx depcheck — find unused dependencies
npx depcheck

# npm outdated — see what's behind
npm outdated

Content Security Policy

Add a CSP header to prevent XSS even if a vulnerability slips through. In Next.js:

// next.config.ts
const csp = `
  default-src 'self';
  script-src 'self' 'strict-dynamic' 'nonce-REPLACE_WITH_NONCE';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.yourdomain.com;
  frame-ancestors 'none';
`.replace(/\n/g, '');

React-Specific Security Checklist

  • No dangerouslySetInnerHTML without DOMPurify sanitization
  • URL attributes validated — block javascript: URIs
  • No secrets in NEXT_PUBLIC_, REACT_APP_, or VITE_ prefixed vars
  • Client-side route guards exist, but server-side enforcement is the real control
  • No eval() or new Function() on user input
  • postMessage handlers validate origin
  • npm audit runs in CI with --audit-level=high
  • Dependencies reviewed for abandonment and known vulnerabilities
  • CSP header configured
  • All <a target="_blank"> links have rel="noopener noreferrer"

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.