Install any skill in seconds. Free to start, no credit card required.
Get Started Free →React frontend security best practices for building secure, production-grade applications. Use when writing or reviewing security-sensitive React code, configuring authentication, handling user input, setting up CSP, securing Server Components/Actions, or hardening a React/Next.js application. Covers OWASP 2025, XSS prevention, CSRF, auth patterns, token storage, Server Components security (CVE-2025-55182), Next.js middleware/proxy hardening, supply chain security, CSP with Vite 6/Next.js 16, Zu
.claude/skills/mamamou-react-security/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-12 | ✗→✓ | ▲ Improved | 183% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 217% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 218% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 226% | 0% |
| case-20 | ✗→✓ | ▲ Improved | 201% | 0% |
A comprehensive guide for building secure, production-grade React applications. Covers React 19, Next.js 16, Vite 6, and aligns with OWASP Top 10:2025.
| Library | Status | Best for | | ---------------------- | --------------------------- | -------------------------------------------------------- | | Better Auth | Active, recommended | New projects — TypeScript-first, plugin system, SSO/SAML | | Clerk | Active, hosted | Pre-built UI, managed backend, free up to 10K MAU | | Auth.js (NextAuth) | Maintenance mode | Existing projects only — security patches only | | Lucia Auth | Deprecated (March 2025) | Do not use — plan migration |
typescript// Store tokens in memory only const AuthProvider = ({ children }: { children: ReactNode }) => { const [accessToken, setAccessToken] = useState<string | null>(null); return ( <AuthContext.Provider value={{ accessToken, setAccessToken }}> {children} </AuthContext.Provider> ); }; // Token injection via interceptor api.interceptors.request.use((config) => { const token = getAccessToken(); // from memory/context if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // Handle 401 with token refresh api.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { await refreshAccessToken(); return api.request(error.config); } return Promise.reject(error); } );
An unauthenticated remote code execution vulnerability in React Server Components' Flight protocol. Affected all RSC frameworks (Next.js, React Router, Waku, etc.). Insecure deserialization allowed arbitrary server-side code execution via a single crafted HTTP request.
Affected: react-server-dom-webpack, react-server-dom-parcel, react-server-dom-turbopack (React 19.0–19.2.0). Apps were vulnerable even without directly implementing Server Functions.
Fix: Update to React 19.0.4+, 19.1.5+, or 19.2.4+.
Server Actions are public HTTP endpoints. Treat them as API routes:
typescript"use server"; import { z } from "zod"; import { getSession } from "@/lib/auth"; const updateUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), }); export async function updateUser(formData: FormData) { // 1. Always authenticate const session = await getSession(); if (!session) throw new Error("Unauthorized"); // 2. Always validate input const parsed = updateUserSchema.safeParse({ name: formData.get("name"), email: formData.get("email"), }); if (!parsed.success) throw new Error("Invalid input"); // 3. Always authorize if (session.user.id !== formData.get("userId")) { throw new Error("Forbidden"); } // 4. Perform the operation await db.user.update({ where: { id: session.user.id }, data: parsed.data }); }
next-safe-action for type-safe Server Actions with built-in validationAttackers could bypass all middleware by sending an x-middleware-subrequest header. Fixed in Next.js 15.2.3+.
middleware.ts renamed to proxy.ts — clarifies this is a network boundary, NOT a security boundary"use cache" directive — include user-bound arguments; call updateTag() after auth/role mutations to avoid stale cached permissionstypescript// proxy.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'nonce-${nonce}'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.yourdomain.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; `.replace(/\n/g, ""); const response = NextResponse.next(); response.headers.set("Content-Security-Policy", cspHeader); response.headers.set("x-nonce", nonce); return response; }
Next.js automatically extracts the nonce during rendering and applies it to script/style tags.
typescriptconst ProtectedRoute = ({ children, requiredRole, }: { children: ReactNode; requiredRole?: string; }) => { const { user, isAuthenticated } = useAuth(); const location = useLocation(); if (!isAuthenticated) { return <Navigate to="/login" state={{ from: location }} replace />; } if (requiredRole && !user.roles.includes(requiredRole)) { return <Navigate to="/unauthorized" replace />; } return children; };
Important: Client-side route guards are UX convenience only. Always enforce authorization on the server (in Server Actions, API routes, or data access layer).
javascript: protocol injectioneval(), Function())typescriptimport DOMPurify from "dompurify"; import { z } from "zod"; // Schema validation const userSchema = z.object({ email: z.string().email(), age: z.number().min(18).max(120), username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/), }); // Sanitize HTML content — encapsulate in a reusable component function SafeHTML({ html }: { html: string }) { const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: ["b", "i", "em", "strong", "a"], ALLOWED_ATTR: ["href"], }); return <div dangerouslySetInnerHTML={{ __html: sanitized }} />; } // File upload validation function validateFile(file: File): boolean { const allowedTypes = ["image/jpeg", "image/png", "image/gif"]; const maxSize = 5 * 1024 * 1024; // 5MB if (!allowedTypes.includes(file.type)) throw new Error("Invalid file type"); if (file.size > maxSize) throw new Error("File too large"); return true; }
dangerouslySetInnerHTMLreact-markdown — converts Markdown to React components without innerHTML; inherently XSS-safehtml-react-parser — parses HTML into JSX elements (still sanitize input first)dangerouslySetInnerHTML in code scanning; always pair with DOMPurifyReact auto-escapes values in JSX by default — {userInput} is safe. The risk is only with dangerouslySetInnerHTML or when constructing HTML strings manually.
Vite has no built-in nonce support. Configure CSP through your server/hosting layer:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.yourdomain.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'Use nonce-based CSP generated per-request in proxy.ts (see Section 3).
Tailwind v4 generates styles at build time (CSS file output) — compatible with style-src hash or nonce. No inline styles by default, which simplifies CSP.
typescript// ❌ BAD — raw user input <div dangerouslySetInnerHTML={{ __html: userInput }} /> // ✅ GOOD — sanitized <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} /> // ✅ BETTER — avoid dangerouslySetInnerHTML entirely <div>{userInput}</div> // React auto-escapes
'unsafe-eval' (React debugging tools)'unsafe-eval'X-Requested-With) to AJAX requeststypescript// Configure CSRF in axios axios.defaults.xsrfCookieName = "csrftoken"; axios.defaults.xsrfHeaderName = "X-CSRFToken"; axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
Server Actions use POST and automatically compare Origin vs Host headers. SameSite cookies provide the default CSRF protection.
typescriptconst maskCreditCard = (cc: string): string => cc.replace(/\d(?=\d{4})/g, "*"); // "1234567890123456" → "************3456" const maskEmail = (email: string): string => { const [local, domain] = email.split("@"); return `${local.slice(0, 2)}***@${domain}`; }; // "user@example.com" → "us***@example.com"
AbortController (axios CancelToken is deprecated)typescriptuseEffect(() => { const controller = new AbortController(); api .get("/users", { signal: controller.signal }) .then(({ data }) => setUsers(data)) .catch((err) => { if (!axios.isCancel(err)) throw err; // Ignore cancellation }); return () => controller.abort(); }, []);
typescriptimport { z } from "zod"; const responseSchema = z.object({ success: z.boolean(), data: z.object({ id: z.string(), name: z.string() }), }); api.interceptors.response.use((response) => { const validated = responseSchema.parse(response.data); return { ...response, data: validated }; });
Supply chain attacks are now a top-3 OWASP category. The npm ecosystem has seen major incidents:
| Date | Incident | | -------- | --------------------------------------------------------------------------------------------------- | | Sep 2025 | CISA alert: 18 popular npm packages (chalk, debug, ansi-styles) compromised via maintainer phishing | | Nov 2025 | Self-replicating npm worm: 796 packages, 132M monthly downloads | | Jan 2026 | "PackageGate": 6 zero-days across npm, pnpm, vlt, Bun undermining lockfile trust |
@fs path traversal bypasspostinstall scripts; use allowBuilds to whitelist trusted packagesnpm audit / pnpm audit in CIhtml<script src="https://cdn.example.com/library.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux..." crossorigin="anonymous" ></script>
devtools middleware must be explicitly disabled in productiontypescriptimport { devtools } from "zustand/middleware"; const useStore = create( process.env.NODE_ENV === "development" ? devtools(storeImpl) : storeImpl, );
nonce prop for strict CSP environmentsprocess.env.NODE_ENV check or enabled: falsebash# .env — never commit secrets VITE_API_URL=https://api.example.com # Don't prefix secrets with VITE_ — they get bundled into client code
typescript// vite.config.ts export default defineConfig({ build: { sourcemap: false, // Disable in production minify: "terser", terserOptions: { compress: { drop_console: true, drop_debugger: true, }, }, }, });
Configure at the server/CDN level (not in the app):
Content-Security-Policy: [see sections 3 and 6]
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadNote: Setting security headers via <meta> tags (react-helmet) is a fallback — server-side headers are more reliable and harder to bypass.
typescriptimport * as Sentry from "@sentry/react"; Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, beforeSend(event) { // Filter sensitive data before sending if (event.user) delete event.user.email; return event; }, }); // Error boundary (use Sentry.ErrorBoundary or custom) <Sentry.ErrorBoundary fallback={<ErrorFallback />}> <App /> </Sentry.ErrorBoundary>
| Layer | Tool | | ----------------- | ------------------------------------- | | Unit/Component | Vitest + React Testing Library | | Security scanning | Socket.dev, Snyk, npm audit | | E2E | Playwright | | Accessibility | axe-core | | Performance | Lighthouse |
typescriptimport { render } from "@testing-library/react"; import DOMPurify from "dompurify"; test("sanitizes XSS attempt", () => { const malicious = '<img src=x onerror=alert("XSS")>'; const { container } = render( <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(malicious) }} /> ); expect(container.querySelector("img")).not.toHaveAttribute("onerror"); }); test("protected route redirects unauthenticated users", () => { render(<ProtectedRoute><Dashboard /></ProtectedRoute>); expect(screen.queryByText("Dashboard")).not.toBeInTheDocument(); });
Where applicable:
| Rank | Category | React Relevance | | ------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------- | | A01 | Broken Access Control | High — client-side guards are UX only; enforce on server | | A02 | Security Misconfiguration | High — CSP, CORS, debug modes in prod | | A03 | Supply Chain Failures (NEW) | Critical — npm ecosystem attacks | | A04 | Cryptographic Failures | Medium — token handling, HTTPS | | A05 | Injection | Lower for React (JSX auto-escapes) — still risk with dangerouslySetInnerHTML and Server Actions | | A06 | Insecure Design | Medium — threat modeling for RSC architecture | | A07 | Auth Failures | High — see Section 1 | | A08 | Software & Data Integrity | High — CI/CD pipeline security | | A09 | Logging & Alerting Failures | Medium — client-side error reporting | | A10 | Mishandling Exceptional Conditions (NEW) | Medium — error boundaries, fail closed |
Before deploying to production:
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-12 | fail→pass | 16,686 | 15,052 | -10% | 1 | 1 | 0% | 3,019 | 8,533 | +183% | 0 | 0 | — |
case-13 | pass→pass | 7,847 | 7,090 | -10% | 1 | 1 | 0% | 1,330 | 7,019 | +428% | 0 | 0 | — |
case-14 | pass→pass | 15,868 | 10,008 | -37% | 1 | 1 | 0% | 1,955 | 7,764 | +297% | 0 | 0 | — |
case-01 | fail→pass | 12,846 | 12,539 | -2% | 1 | 1 | 0% | 2,661 | 8,441 | +217% | 0 | 0 | — |
case-02 | pass→fail | 14,817 | 13,486 | -9% | 1 | 1 | 0% | 2,749 | 8,367 | +204% | 0 | 0 | — |
case-15 | fail→fail | 18,559 | 19,593 | +6% | 1 | 1 | 0% | 3,071 | 9,129 | +197% | 0 | 0 | — |
case-03 | pass→pass | 15,575 | 6,576 | -58% | 1 | 1 | 0% | 2,532 | 7,045 | +178% | 0 | 0 | — |
case-04 | fail→pass | 13,996 | 8,487 | -39% | 1 | 1 | 0% | 2,313 | 7,357 | +218% | 0 | 0 | — |
case-05 | fail→pass | 14,457 | 14,168 | -2% | 1 | 1 | 0% | 2,510 | 8,189 | +226% | 0 | 0 | — |
case-06 | pass→pass | 13,709 | 17,450 | +27% | 1 | 1 | 0% | 2,539 | 7,440 | +193% | 0 | 0 | — |
case-07 | pass→pass | 9,376 | 10,536 | +12% | 1 | 1 | 0% | 1,834 | 7,837 | +327% | 0 | 0 | — |
case-16 | fail→fail | 15,050 | 19,292 | +28% | 1 | 1 | 0% | 2,401 | 8,915 | +271% | 0 | 0 | — |
case-08 | fail→fail | 16,227 | 15,804 | -3% | 1 | 1 | 0% | 2,245 | 8,876 | +295% | 0 | 0 | — |
case-09 | pass→pass | 16,540 | 13,164 | -20% | 1 | 1 | 0% | 3,119 | 8,459 | +171% | 0 | 0 | — |
case-10 | pass→pass | 12,284 | 16,696 | +36% | 1 | 1 | 0% | 1,681 | 7,790 | +363% | 0 | 0 | — |
case-11 | pass→pass | 13,803 | 14,444 | +5% | 1 | 1 | 0% | 2,672 | 8,565 | +221% | 0 | 0 | — |
case-17 | pass→pass | 14,529 | 13,811 | -5% | 1 | 1 | 0% | 2,662 | 8,972 | +237% | 0 | 0 | — |
case-18 | pass→pass | 15,317 | 43,063 | +181% | 1 | 1 | 0% | 2,813 | 8,201 | +192% | 0 | 0 | — |
case-19 | pass→pass | 15,700 | 18,931 | +21% | 1 | 1 | 0% | 2,578 | 8,989 | +249% | 0 | 0 | — |
case-20 | fail→pass | 29,838 | 23,439 | -21% | 1 | 1 | 0% | 3,354 | 10,087 | +201% | 0 | 0 | — |
case-21 | pass→pass | 13,369 | 17,777 | +33% | 1 | 1 | 0% | 2,532 | 9,161 | +262% | 0 | 0 | — |
case-22 | pass→pass | 5,918 | 5,884 | -1% | 1 | 1 | 0% | 1,132 | 6,978 | +516% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.