Skip to main content
Back to blog
March 7, 2026|10 min read|Hardening Guides

React Security Best Practices: 12 Things Every Developer Should Do

A developer's checklist for React security. Covers XSS prevention, dangerouslySetInnerHTML, dependency security, CSP, and more with code examples.

ZeriFlow Team

1,595 words

XSS in React: How It Happens

React is often praised for being "secure by default" against XSS attacks. And it's true — React's JSX automatically escapes values before rendering them:

jsx
// Safe — React escapes the value
const userInput = '<script>alert("xss")</script>';
return <div>{userInput}</div>;
// Renders as text: <script>alert("xss")</script>

But "secure by default" doesn't mean "impossible to break." Here are the ways XSS still happens in React applications:

### 1. dangerouslySetInnerHTML The most obvious vector. React named it "dangerously" for a reason:

jsx
// VULNERABLE — unsanitized user input rendered as HTML
return <div dangerouslySetInnerHTML={{ __html: userComment }} />;

### 2. href and src Attributes React does not sanitize URL attributes:

jsx
// VULNERABLE — javascript: protocol executes code
const userUrl = 'javascript:alert(document.cookie)';
return <a href={userUrl}>Click here</a>;

3. eval() and Dynamic Code

jsx
// VULNERABLE — executing user-controlled strings
eval(userInput);
new Function(userInput)();
setTimeout(userInput, 0); // string form

### 4. Server-Side Rendering (SSR) When rendering React on the server, the initial HTML is sent as a string. If user input is included in that string without escaping, XSS happens before React even hydrates.

dangerouslySetInnerHTML — When and How to Use It Safely

Sometimes you need to render HTML — blog posts from a CMS, rich text editor output, or formatted content from an API. Here's how to do it safely:

Always Sanitize with DOMPurify

jsx
import DOMPurify from 'dompurify';

function SafeHtml({ html }) {
  const sanitized = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'h2', 'h3', 'code', 'pre'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
  });

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

### Key Rules 1. Never skip sanitization — even for "trusted" sources. APIs get compromised, databases get injected. 2. Whitelist, don't blacklist — Specify allowed tags and attributes explicitly. 3. Use a mature library — DOMPurify is battle-tested. Don't write your own sanitizer. 4. Sanitize on render, not on storage — Sanitization rules change; always clean at the point of output.

Content Security Policy for React Apps

A Content Security Policy (CSP) is your second line of defense against XSS. Even if an attacker finds a way to inject a script, CSP can prevent it from executing.

### The Challenge with React React and many CSS-in-JS libraries use inline styles and sometimes inline scripts, which conflict with strict CSP. Here's how to handle it:

javascript
// next.config.js — Next.js CSP with nonce
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: `
              default-src 'self';
              script-src 'self' 'nonce-{NONCE}';
              style-src 'self' 'unsafe-inline';
              img-src 'self' data: https:;
              font-src 'self';
              connect-src 'self' https://api.example.com;
            `.replace(/\n/g, ' ').trim(),
          },
        ],
      },
    ];
  },
};

### Report-Only Mode First Always start with Content-Security-Policy-Report-Only to see what would break before enforcing:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

Monitor the reports for a week, adjust your policy, then switch to enforcing mode.

Avoiding Injection in Props

### URL Validation Always validate URLs before using them in href or src:

jsx
function SafeLink({ url, children }) {
  const isValidUrl = (url) => {
    try {
      const parsed = new URL(url);
      return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
    } catch {
      return false;
    }
  };

  if (!isValidUrl(url)) {
    return <span>{children}</span>;
  }

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

### Prop Spreading Avoid spreading user-controlled objects into components:

jsx
// DANGEROUS — user controls which props are set
const userProps = getUserProps(); // { onError: 'alert(1)', style: '...' }
return <div {...userProps} />;

// SAFE — explicitly pick allowed props
const { title, className } = getUserProps();
return <div title={title} className={className} />;

Secure Authentication Patterns

Token Storage

jsx
// BAD — accessible via XSS
localStorage.setItem('token', jwt);

// BETTER — httpOnly cookie (set by server)
// The token is never accessible to JavaScript
// Server sets: Set-Cookie: token=jwt; HttpOnly; Secure; SameSite=Strict

Protected Routes

jsx
function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();

  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/login" replace />;

  return children;
}

Session Timeout

jsx
function useSessionTimeout(timeoutMs = 30 * 60 * 1000) {
  useEffect(() => {
    let timer;
    const resetTimer = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        logout();
        navigate('/login?reason=timeout');
      }, timeoutMs);
    };

    window.addEventListener('mousemove', resetTimer);
    window.addEventListener('keypress', resetTimer);
    resetTimer();

    return () => {
      clearTimeout(timer);
      window.removeEventListener('mousemove', resetTimer);
      window.removeEventListener('keypress', resetTimer);
    };
  }, [timeoutMs]);
}

Protecting API Keys and Secrets

Never in Client Code

jsx
// WRONG — visible in browser source
const API_KEY = 'sk_live_abc123';
fetch(`https://api.stripe.com/v1/charges`, {
  headers: { Authorization: `Bearer ${API_KEY}` }
});

// RIGHT — proxy through your backend
fetch('/api/create-charge', {
  method: 'POST',
  body: JSON.stringify({ amount: 1000 }),
});

Environment Variables

bash
# .env — NOT committed to git
NEXT_PUBLIC_API_URL=https://api.example.com  # OK — public
STRIPE_SECRET_KEY=sk_live_xxx                # Server-only

# .gitignore
.env
.env.local
.env.production

Rule: Any variable prefixed with NEXT_PUBLIC_ or REACT_APP_ is bundled into client code and visible to everyone. Never put secrets in these.

Dependency Auditing (npm audit)

Your React app likely has hundreds of dependencies. Each one is a potential attack vector.

Regular Auditing

bash
# Check for known vulnerabilities
npm audit

# Auto-fix where possible
npm audit fix

# See detailed report
npm audit --json

Automated in CI/CD

yaml
# GitHub Actions
- name: Security audit
  run: npm audit --audit-level=high
  # Fails the build if high or critical vulnerabilities exist

### Lock File Integrity Always commit your package-lock.json or yarn.lock. This ensures everyone installs the exact same versions and prevents supply chain attacks through version ranges.

### Dependency Review Before adding a new package: 1. Check download count and maintenance status on npm 2. Look for recent security advisories 3. Review the package's own dependencies 4. Consider if you can implement the functionality yourself

CORS in React Apps

CORS (Cross-Origin Resource Sharing) is configured on your backend, not in React. But understanding it prevents common mistakes:

javascript
// Backend (Express) — strict CORS configuration
const cors = require('cors');
app.use(cors({
  origin: ['https://your-app.com'],  // NOT '*' in production
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

Common mistake: Setting Access-Control-Allow-Origin: * to "fix" CORS errors. This allows any website to make requests to your API, which is a security risk if your API handles authenticated requests.

Input Validation

Validate all user input — both in React (for UX) and on the backend (for security):

jsx
function ContactForm() {
  const validateEmail = (email) => {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  };

  const sanitizeInput = (input) => {
    return input.trim().slice(0, 1000); // Limit length
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const email = sanitizeInput(e.target.email.value);
    const message = sanitizeInput(e.target.message.value);

    if (!validateEmail(email)) {
      setError('Invalid email');
      return;
    }
    // Send to backend — backend validates again
  };
}

Client-side validation is for UX, not security. Always validate on the server.

Third-Party Libraries

Review What You Import

jsx
// Instead of importing all of lodash (71KB)
import _ from 'lodash';

// Import only what you need
import debounce from 'lodash/debounce';

### Subresource Integrity (SRI) For CDN-loaded scripts, use SRI to ensure they haven't been tampered with:

html
<script
  src="https://cdn.example.com/library.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossorigin="anonymous"
></script>

### Content Security Policy Limit which external sources your app can load resources from:

Content-Security-Policy: script-src 'self' https://cdn.trusted.com;

Server-Side Rendering (SSR) Security

SSR introduces unique security considerations:

Serialization Attacks

jsx
// VULNERABLE — user data in serialized state
<script
  dangerouslySetInnerHTML={{
    __html: `window.__INITIAL_STATE__ = ${JSON.stringify(state)}`
  }}
/>

// SAFE — use a serialization library that escapes HTML
import serialize from 'serialize-javascript';
<script
  dangerouslySetInnerHTML={{
    __html: `window.__INITIAL_STATE__ = ${serialize(state, { isJSON: true })}`
  }}
/>

### Environment Variable Leakage Server-side code has access to all environment variables. Make sure sensitive values don't get serialized into the client-side HTML:

javascript
// getServerSideProps — runs on server only
export async function getServerSideProps() {
  const data = await fetch(process.env.INTERNAL_API_URL, {
    headers: { Authorization: `Bearer ${process.env.API_SECRET}` }
  });

  // Only pass sanitized data to the client
  return { props: { items: data.items } };
  // NOT: return { props: { items: data.items, apiSecret: process.env.API_SECRET } }
}

Build Your Security Scan into CI/CD

Security shouldn't be a manual step. Automate it:

yaml
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Dependency audit
        run: npm audit --audit-level=high

      - name: Lint for security issues
        run: npx eslint . --plugin security

      - name: Build
        run: npm run build

      - name: Check bundle for secrets
        run: npx secretlint "build/**/*.js"

### Post-Deployment Verification After deploying, run an automated security scan to verify your production configuration. Tools like ZeriFlow can check your deployed site's headers, TLS configuration, and security posture in under 60 seconds — integrate it into your deployment pipeline for continuous verification.

Security in React isn't a one-time checklist. It's a set of practices that should be embedded in your development workflow, from the first line of code to every production deployment.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading