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:
- Stored XSS — The malicious script is saved on the server (e.g., in a database) and served to all users who view that content.
- Reflected XSS — The script is embedded in a URL and reflected back to the user by the server without proper sanitization.
- 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 defaultscript-src 'nonce-...'— only execute scripts with a matching server-generated nonceframe-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
1. SameSite Cookie Attribute
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 requestsSameSite=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.
3. Double Submit Cookie Pattern
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 Location | XSS Risk | CSRF Risk | Verdict |
|---|---|---|---|
localStorage | High (JS readable) | None | ❌ Avoid for sensitive tokens |
sessionStorage | High (JS readable) | None | ❌ Avoid for sensitive tokens |
| In-memory (JS variable) | Low (gone on refresh) | None | ✅ Good for short-lived tokens |
HttpOnly Cookie | None (not JS readable) | Medium (needs SameSite) | ✅ Best for session tokens |
The recommended 2026 approach:
- Store refresh tokens in
HttpOnly; Secure; SameSite=Strictcookies (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:anddata:protocols - ✅ Add
rel="noopener noreferrer"to all external links withtarget="_blank" - ✅ Implement a strong Content Security Policy (CSP)
- ✅ Set
X-Content-Type-Options: nosniffandX-Frame-Options: DENYheaders
CSRF Prevention
- ✅ Use
SameSite=StrictorSameSite=Laxcookies 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
OriginandRefererheaders server-side
API Security
- ✅ Store refresh tokens in
HttpOnly; Securecookies - ✅ 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-securityandeslint-plugin-no-unsanitizedcan catch many XSS vulnerabilities at write time, not review time. -
Implement Subresource Integrity (SRI): When loading third-party scripts from a CDN, add an
integrityattribute 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 typeshttp://. Set it on your server with a longmax-age:Strict-Transport-Security: max-age=31536000; includeSubDomains; preload -
Use
encodeURIComponentfor 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
SameSitecookies — set your session cookies withSameSite=LaxorSameSite=Strictat a minimum. - Never store JWTs or sensitive tokens in
localStorage— useHttpOnlycookies 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 auditand 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
- OWASP XSS Prevention Cheat Sheet
- OWASP CSRF Prevention Cheat Sheet
- MDN Web Docs — Content Security Policy
- MDN Web Docs — SameSite Cookies
- DOMPurify on GitHub
- Zod — TypeScript-first schema validation
- React Security Fundamentals by Ryan Chenkie
- Auth0 — Secure React SPAs
- Web.dev — Security Headers
- ESLint Plugin Security