Skip to main content
Back to Blog
ReactSecurityXSSCSRFFrontendAPIJavaScriptWeb SecurityAuthenticationBest Practices

Securing React Apps: XSS, CSRF, and Secure API Integration

A comprehensive guide to frontend security in React — learn how to defend against XSS and CSRF attacks, integrate safely with backend APIs, handle authentication tokens, and adopt 2026 security best practices.

May 1, 202617 min readNiraj Kumar

Security is one of those topics every developer knows they should care about — but often pushes to the bottom of the backlog. In frontend development, especially with React, it's tempting to assume the backend handles all the dangerous stuff. That assumption has cost companies millions of dollars and users their privacy.

In this guide, we'll go deep on the most critical frontend vulnerabilities — Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF) — and walk through practical, modern patterns for secure API integration in React apps. Whether you're just starting out or have a few production apps under your belt, this guide will give you concrete tools and mental models to ship more secure code.


Why Frontend Security Matters More Than Ever

The modern React app is no longer a thin UI layer. With server components, edge functions, API routes, and direct database access via BFF (Backend for Frontend) patterns, the line between "frontend" and "backend" has blurred significantly. A vulnerability in your React code can now mean:

  • Stolen authentication tokens
  • Hijacked user sessions
  • Exfiltrated sensitive data
  • Full account takeover

The OWASP Top 10 Web Application Security Risks consistently lists injection attacks and broken access control near the top. Many of these vulnerabilities have a direct frontend component. Understanding them is no longer optional.


Understanding XSS: Cross-Site Scripting

What Is XSS?

Cross-Site Scripting (XSS) is an attack where malicious scripts are injected into a trusted website and executed in the victim's browser. When successful, an attacker can:

  • Steal cookies and session tokens
  • Redirect users to phishing pages
  • Capture keystrokes and form input
  • Perform actions on behalf of the user

There are three main types of XSS:

  1. Stored XSS — The malicious script is saved on the server (e.g., in a database) and served to all users who view that content.
  2. Reflected XSS — The script is embedded in a URL and reflected back to the user by the server without proper sanitization.
  3. DOM-based XSS — The vulnerability exists entirely in the client-side code; the attack payload is processed by JavaScript in the DOM without going through the server.

XSS in React: The Good News

React's JSX by default escapes values before rendering them into the DOM. This means if a user submits a comment like:

<script>alert('hacked')</script>

And you render it naively:

const Comment = ({ text }) => <p>{text}</p>;

React will render the raw string <script>alert('hacked')</script> as text — not as executable HTML. This is a huge win and eliminates a large class of XSS vulnerabilities out of the box.

The Dangerous Escape Hatch: dangerouslySetInnerHTML

Here's where things get risky. React provides dangerouslySetInnerHTML to set raw HTML — and the name is a deliberate warning. If you pass unsanitized user content through it, you open the door to XSS:

// ❌ DANGEROUS — never do this with user input
const BlogPost = ({ htmlContent }) => (
  <div dangerouslySetInnerHTML={{ __html: htmlContent }} />
);

If htmlContent comes from a user or an untrusted source, an attacker can inject a <script> tag or an <img onerror="..."> payload.

The safe approach: Always sanitize HTML before using dangerouslySetInnerHTML. The most popular and reliable library for this is DOMPurify:

npm install dompurify
npm install --save-dev @types/dompurify
// ✅ SAFE — sanitize first, then render
import DOMPurify from "dompurify";

const BlogPost = ({ htmlContent }) => {
  const clean = DOMPurify.sanitize(htmlContent, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li"],
    ALLOWED_ATTR: ["href", "target", "rel"],
  });

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

Note the explicit allowlist of tags and attributes. This is the principle of least privilege applied to HTML — only allow what you actually need.

DOM-Based XSS in React

Even without dangerouslySetInnerHTML, DOM-based XSS can sneak in through other vectors:

// ❌ VULNERABLE — user controls the URL, including the protocol
const ExternalLink = ({ url, label }) => <a href={url}>{label}</a>;

// An attacker can pass: javascript:alert(document.cookie)

The fix: Validate and sanitize URLs explicitly:

// ✅ SAFE — validate URL protocol before rendering
const SAFE_PROTOCOLS = ["http:", "https:", "mailto:"];

function sanitizeUrl(url) {
  try {
    const parsed = new URL(url);
    if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
      return "#"; // fallback to safe no-op
    }
    return url;
  } catch {
    return "#";
  }
}

const ExternalLink = ({ url, label }) => (
  <a
    href={sanitizeUrl(url)}
    target="_blank"
    rel="noopener noreferrer"
  >
    {label}
  </a>
);

Always include rel="noopener noreferrer" on target="_blank" links. Without noopener, the opened page can access window.opener and potentially redirect the parent page.

Content Security Policy (CSP)

A Content Security Policy is a browser-enforced security layer that restricts where scripts, styles, images, and other resources can be loaded from. Even if XSS code is injected, a strong CSP can prevent it from executing or phoning home.

Configure CSP via HTTP headers in your server or via a <meta> tag:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://cdn.example.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';

Key directives:

  • default-src 'self' — only load resources from the same origin by default
  • script-src 'nonce-...' — only execute scripts with a matching server-generated nonce
  • frame-ancestors 'none' — prevent your app from being embedded in iframes (also mitigates clickjacking)

In a Next.js app, you can set CSP headers in next.config.js or via middleware:

// middleware.ts (Next.js 14+)
import { NextResponse } from "next/server";
import crypto from "crypto";

export function middleware(request) {
  const nonce = crypto.randomBytes(16).toString("base64");
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    connect-src 'self' https://api.example.com;
    frame-ancestors 'none';
  `.replace(/\s{2,}/g, " ").trim();

  const response = NextResponse.next();
  response.headers.set("Content-Security-Policy", cspHeader);
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  return response;
}

Understanding CSRF: Cross-Site Request Forgery

What Is CSRF?

CSRF (pronounced "sea-surf") tricks an authenticated user into unknowingly submitting a malicious request to a web application they're logged into. The key insight: the browser automatically sends cookies with every request to a matching domain, even if the request was initiated by a different website.

Imagine a user is logged into bank.com. An attacker on evil.com includes:

<!-- On evil.com -->
<img
  src="https://bank.com/transfer?to=attacker&amount=5000"
  style="display:none"
/>

When the user visits evil.com, their browser fires the request to bank.com with their session cookie attached. If the bank doesn't defend against CSRF, the transfer goes through.

Modern CSRF Defenses

The most effective modern defense is the SameSite cookie attribute, which tells the browser when to send cookies with cross-origin requests:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
  • SameSite=Strict — cookies are never sent with cross-site requests
  • SameSite=Lax — cookies are sent with top-level navigations (like clicking a link), but not sub-requests (images, iframes, fetch calls)
  • SameSite=None; Secure — cookies are sent with all cross-site requests (use only when you explicitly need cross-origin cookies)

For most applications, SameSite=Lax provides a good balance of security and usability. SameSite=Strict is ideal for highly sensitive applications like banking.

Note: Cookie security is configured server-side, but as a frontend developer, you need to understand this to correctly architect your authentication flow.

2. CSRF Tokens (Synchronizer Token Pattern)

For applications that need to support older browsers or that use cookie-based auth without SameSite, CSRF tokens are the traditional defense:

// Step 1: Fetch a CSRF token from your server on app load
async function getCsrfToken() {
  const res = await fetch("/api/csrf-token", {
    credentials: "include",
  });
  const { token } = await res.json();
  return token;
}

// Step 2: Include it in every state-changing request
async function transferFunds({ to, amount, csrfToken }) {
  const res = await fetch("/api/transfer", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken, // custom header
    },
    credentials: "include",
    body: JSON.stringify({ to, amount }),
  });
  return res.json();
}

The server validates that the X-CSRF-Token header matches the token it issued. Since cross-origin requests from a malicious page cannot set custom headers (blocked by CORS), this defense is effective.

A stateless alternative: set a random value in both a cookie and a request parameter or header. The server validates they match. Since cross-origin scripts can't read cookies from another domain, this works as a CSRF defense:

// Read the CSRF cookie (set by server, not HttpOnly)
function getCsrfCookie() {
  const match = document.cookie.match(/csrftoken=([^;]+)/);
  return match ? match[1] : null;
}

// Include in requests
async function apiPost(endpoint, body) {
  return fetch(endpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRFToken": getCsrfCookie(),
    },
    credentials: "include",
    body: JSON.stringify(body),
  });
}

This pattern is popular in Django-based backends and works well in React SPAs.


Secure API Integration in React

Managing Authentication Tokens Safely

One of the most contested topics in React security is where to store authentication tokens. Here's the honest breakdown:

Storage LocationXSS RiskCSRF RiskVerdict
localStorageHigh (JS readable)None❌ Avoid for sensitive tokens
sessionStorageHigh (JS readable)None❌ Avoid for sensitive tokens
In-memory (JS variable)Low (gone on refresh)None✅ Good for short-lived tokens
HttpOnly CookieNone (not JS readable)Medium (needs SameSite)✅ Best for session tokens

The recommended 2026 approach:

  • Store refresh tokens in HttpOnly; Secure; SameSite=Strict cookies (managed by the server)
  • Store access tokens in memory (a React context or Zustand store) — they're short-lived and disappear on page refresh
  • Use a token refresh endpoint to silently get a new access token using the refresh token cookie
// auth-context.jsx
import { createContext, useContext, useRef, useCallback } from "react";

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  // Access token lives only in memory — not in localStorage!
  const accessTokenRef = useRef(null);

  const getAccessToken = useCallback(async () => {
    if (accessTokenRef.current) {
      return accessTokenRef.current;
    }
    // Silently refresh using the HttpOnly refresh token cookie
    const res = await fetch("/api/auth/refresh", {
      method: "POST",
      credentials: "include", // sends the HttpOnly cookie
    });
    if (!res.ok) throw new Error("Session expired");
    const { accessToken } = await res.json();
    accessTokenRef.current = accessToken;

    // Clear from memory after expiry (e.g., 15 minutes)
    setTimeout(() => {
      accessTokenRef.current = null;
    }, 15 * 60 * 1000);

    return accessToken;
  }, []);

  return (
    <AuthContext.Provider value={{ getAccessToken }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

Building a Secure API Client

Rather than sprinkling fetch calls throughout your app, build a centralized API client that handles auth, CSRF, and error handling consistently:

// lib/api-client.js
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;

class ApiClient {
  constructor(getAccessToken) {
    this.getAccessToken = getAccessToken;
  }

  async request(endpoint, options = {}) {
    const { method = "GET", body, headers = {} } = options;

    // Get the in-memory access token
    const token = await this.getAccessToken();

    const requestHeaders = {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
      ...headers,
    };

    const res = await fetch(`${BASE_URL}${endpoint}`, {
      method,
      headers: requestHeaders,
      credentials: "include", // for cookies (e.g., refresh token)
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!res.ok) {
      const error = await res.json().catch(() => ({ message: res.statusText }));
      throw new ApiError(res.status, error.message);
    }

    if (res.status === 204) return null;
    return res.json();
  }

  get(endpoint, options) {
    return this.request(endpoint, { ...options, method: "GET" });
  }

  post(endpoint, body, options) {
    return this.request(endpoint, { ...options, method: "POST", body });
  }

  put(endpoint, body, options) {
    return this.request(endpoint, { ...options, method: "PUT", body });
  }

  delete(endpoint, options) {
    return this.request(endpoint, { ...options, method: "DELETE" });
  }
}

class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
    this.name = "ApiError";
  }
}

export { ApiClient, ApiError };
// Usage in a component
import { useAuth } from "@/context/auth-context";
import { ApiClient } from "@/lib/api-client";
import { useMemo } from "react";

function useApi() {
  const { getAccessToken } = useAuth();
  return useMemo(() => new ApiClient(getAccessToken), [getAccessToken]);
}

function UserProfile() {
  const api = useApi();
  const [profile, setProfile] = useState(null);

  useEffect(() => {
    api.get("/users/me").then(setProfile).catch(console.error);
  }, [api]);

  return <div>{profile?.name}</div>;
}

Input Validation and Sanitization on the Frontend

Even though the backend should be the ultimate source of truth for validation, frontend validation is your first line of defense and significantly improves the user experience:

// Using Zod for runtime schema validation — works in both browser and Node
import { z } from "zod";

const UserSchema = z.object({
  name: z
    .string()
    .min(1, "Name is required")
    .max(100)
    .regex(/^[a-zA-Z\s'-]+$/, "Name contains invalid characters"),
  email: z.string().email("Invalid email address"),
  age: z.number().int().min(13).max(120).optional(),
  website: z
    .string()
    .url()
    .startsWith("https://", "Must use HTTPS")
    .optional(),
});

async function updateProfile(formData) {
  const result = UserSchema.safeParse(formData);

  if (!result.success) {
    // result.error.format() gives field-level error messages
    throw new ValidationError(result.error.format());
  }

  return api.put("/users/me", result.data);
}

Handling Sensitive Data in the UI

Never log sensitive data or expose it unnecessarily:

// ❌ BAD — exposes token in console, easily captured by XSS
console.log("Auth state:", { user, token, refreshToken });

// ❌ BAD — error boundaries that log full error details to analytics
Sentry.captureException(error, {
  extra: { user, token } // don't include tokens!
});

// ✅ GOOD — log only what you need for debugging
if (process.env.NODE_ENV === "development") {
  console.log("Auth state:", { userId: user?.id, isAuthenticated: !!token });
}

Mask sensitive data in forms where possible:

// Show only last 4 digits of card number in the UI
const MaskedCard = ({ cardNumber }) => (
  <span aria-label="Card ending in">•••• •••• •••• {cardNumber.slice(-4)}</span>
);

Best Practices Checklist

Here's a quick reference for securing your React app end to end:

XSS Prevention

  • ✅ Rely on React's built-in escaping — avoid raw HTML rendering
  • ✅ Sanitize all HTML with DOMPurify before using dangerouslySetInnerHTML
  • ✅ Validate and sanitize URLs — block javascript: and data: protocols
  • ✅ Add rel="noopener noreferrer" to all external links with target="_blank"
  • ✅ Implement a strong Content Security Policy (CSP)
  • ✅ Set X-Content-Type-Options: nosniff and X-Frame-Options: DENY headers

CSRF Prevention

  • ✅ Use SameSite=Strict or SameSite=Lax cookies for session management
  • ✅ Implement CSRF tokens for state-changing requests on older systems
  • ✅ Use custom headers (e.g., X-Requested-With) as a lightweight additional check
  • ✅ Verify Origin and Referer headers server-side

API Security

  • ✅ Store refresh tokens in HttpOnly; Secure cookies
  • ✅ Store access tokens in memory, not localStorage
  • ✅ Always use HTTPS — never HTTP — in production
  • ✅ Centralize API calls through a single authenticated client
  • ✅ Validate all inputs with a schema library like Zod
  • ✅ Never log tokens, passwords, or PII

Common Mistakes to Avoid

Mistake #1: Putting JWTs in localStorage

This is probably the most common security mistake in React apps. localStorage is accessible by any JavaScript on the page — including injected XSS scripts. An attacker who achieves XSS can exfiltrate every user's JWT with a simple localStorage.getItem('token').

Fix: Use HttpOnly cookies for session tokens. If you must use JWTs for stateless auth, keep them short-lived (15 minutes) and store them only in memory.

Mistake #2: Disabling CORS Entirely

During development, developers sometimes configure the backend with Access-Control-Allow-Origin: * and forget to lock it down before production.

Fix: Configure CORS to explicitly allow only your frontend's origin and the specific HTTP methods and headers your API uses.

Mistake #3: Trusting the Frontend for Authorization

// ❌ WRONG — hiding UI elements is not authorization
{user.role === "admin" && <AdminPanel />}

An attacker can modify the role property in React DevTools or intercept network responses. The backend must enforce permissions on every API request, regardless of what the frontend shows.

Mistake #4: Verbose Error Messages

// ❌ BAD — reveals internal structure
catch (error) {
  setError(`Database error: ${error.message} at ${error.stack}`);
}

// ✅ GOOD — generic message for users, detailed logging server-side
catch (error) {
  console.error("[API Error]", error); // server logs, not client
  setError("Something went wrong. Please try again.");
}

Mistake #5: Not Keeping Dependencies Updated

Third-party packages can introduce vulnerabilities. Run security audits regularly:

npm audit
npm audit fix

# For a detailed view
npx better-npm-audit audit

Use tools like Dependabot (GitHub) or Renovate to automate dependency updates and receive alerts when a package has a known CVE.


🚀 Pro Tips

  • Use a security linter: ESLint plugins like eslint-plugin-security and eslint-plugin-no-unsanitized can catch many XSS vulnerabilities at write time, not review time.

  • Implement Subresource Integrity (SRI): When loading third-party scripts from a CDN, add an integrity attribute so the browser can verify the file hasn't been tampered with:

    <script
      src="https://cdn.example.com/lib.min.js"
      integrity="sha384-ABC123..."
      crossorigin="anonymous"
    />
    
  • Use crypto.randomUUID() for client-side IDs: When you need to generate unique IDs in the browser (e.g., for form fields or optimistic UI), use the Web Crypto API instead of Math.random()-based libraries.

  • Audit your third-party scripts: Every third-party script (analytics, chat widgets, A/B testing) runs with the same permissions as your code. Use CSP nonces to control which scripts are allowed, and review what each vendor's script actually does.

  • Rate-limit on the frontend too: While server-side rate limiting is essential, adding client-side debouncing on API calls reduces unintentional hammering and makes brute-force attacks slightly harder:

    import { useDebouncedCallback } from "use-debounce";
    
    const debouncedSearch = useDebouncedCallback(async (query) => {
      const results = await api.get(`/search?q=${encodeURIComponent(query)}`);
      setResults(results);
    }, 300);
    
  • Enable Strict-Transport-Security (HSTS): This HTTP header tells browsers to always use HTTPS for your domain, even if a user types http://. Set it on your server with a long max-age:

    Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
    
  • Use encodeURIComponent for all URL parameters: Never concatenate user input directly into URLs or query strings. Use the built-in encoding functions to prevent injection:

    // ❌ Vulnerable to URL injection
    fetch(`/api/search?q=${query}`);
    
    // ✅ Safe
    fetch(`/api/search?q=${encodeURIComponent(query)}`);
    

📌 Key Takeaways

  • React's JSX escaping is your first line of defense against XSS, but dangerouslySetInnerHTML, dynamic URLs, and third-party scripts can bypass it.
  • Always sanitize HTML with DOMPurify before injecting it into the DOM, using an explicit allowlist of tags and attributes.
  • CSRF is largely mitigated with SameSite cookies — set your session cookies with SameSite=Lax or SameSite=Strict at a minimum.
  • Never store JWTs or sensitive tokens in localStorage — use HttpOnly cookies for refresh tokens and in-memory storage for short-lived access tokens.
  • Build a centralized API client that handles authentication, CSRF tokens, and error handling consistently across your app.
  • Validate inputs with Zod on the frontend, but treat the backend as the final authority on validation and authorization.
  • Content Security Policy is not optional for production apps — even a basic CSP dramatically reduces the blast radius of an XSS vulnerability.
  • Regularly audit your dependencies with npm audit and automate updates with Dependabot or Renovate.
  • Security is a team sport — frontend, backend, and DevOps all need to be aligned on threat models and defense strategies.

Conclusion

Frontend security in 2026 is not just about protecting your backend from bad inputs. As React applications take on more responsibility — handling auth, managing sensitive state, orchestrating complex workflows — the attack surface grows with them.

The good news: most vulnerabilities have clear, well-understood mitigations. React's default escaping behavior, combined with a strong CSP, HttpOnly cookies, DOMPurify sanitization, and a centralized API client, will protect you from the vast majority of real-world attacks.

Security is a mindset shift as much as a technical one. Start incorporating these patterns today, and make security a first-class concern in your code reviews, not an afterthought in your retrospectives.


References

All Articles
ReactSecurityXSSCSRFFrontendAPIJavaScriptWeb SecurityAuthenticationBest Practices

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.