VAPT Remediation OWASP Top 10 React 18 / Next.js Vite · CRA · Nginx

React Security
Hardening Guide

A complete, copy-paste-ready security baseline for React SPAs. Every control targets the unique attack surface of frontend applications — from XSS via JSX to token theft, DevTools leakage, and supply-chain attacks.

16
Controls
50+
Code Snippets
100%
Free / OSS
A+
Security Grade
XSS & Injection
01
XSS Prevention & dangerouslySetInnerHTML
CRITICAL Frontend DOMPurifyJSXdompurify
⚠ Issue
React auto-escapes JSX expressions, but dangerouslySetInnerHTML bypasses this and directly injects raw HTML. Using it with unsanitized user content enables stored/reflected XSS. Also vulnerable: href with javascript: URLs and eval() usage.
🔥 Risk
Stored/reflected XSS enabling session hijacking, credential theft, DOM-based token exfiltration, keylogging, and full account takeover via injected scripts.
📁 Files & Paths
src/components/*.jsxsrc/utils/sanitize.js src/components/RichText.jsx
🔧 Fix 1 — Install DOMPurify & create a safe wrapper
Terminal
$ npm install dompurify
$ npm install --save-dev @types/dompurify  # TypeScript
src/utils/sanitize.js
import DOMPurify from 'dompurify';

// Configure DOMPurify — strict profile
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
  // Block all javascript: href attacks
  if ('href' in node) {
    if (!/^(https?|mailto):/.test(node.href)) {
      node.removeAttribute('href');
    }
  }
  // Force target=_blank links to have rel="noopener noreferrer"
  if (node.getAttribute('target') === '_blank') {
    node.setAttribute('rel', 'noopener noreferrer');
  }
});

const PURIFY_CONFIG = {
  ALLOWED_TAGS: ['p','b','i','em','strong','a','ul','ol','li','br','h2','h3'],
  ALLOWED_ATTR: ['href','target','rel','class'],
  ALLOW_DATA_ATTR: false,
  FORBID_SCRIPTS: true,
  FORBID_TAGS: ['script','iframe','object','embed','form'],
};

export const sanitizeHtml = (dirty) =>
  DOMPurify.sanitize(dirty, PURIFY_CONFIG);

export const sanitizeUrl = (url) => {
  if (!/^(https?):\/\//i.test(url)) return '#';
  return url;
};
src/components/RichText.jsx — Safe HTML renderer
import { sanitizeHtml } from '../utils/sanitize';

// ✗ NEVER: raw unsanitized HTML
// <div dangerouslySetInnerHTML={{ __html: userContent }} />

// ✓ ALWAYS: sanitize before rendering
export function SafeHtml({ content, className }) {
  const clean = sanitizeHtml(content);
  return (
    <div
      className={className}
      dangerouslySetInnerHTML={{ __html: clean }}
    />
  );
}

// ✗ NEVER: javascript: URLs in hrefs
// <a href={userUrl}>Click</a>

// ✓ ALWAYS: validate URL protocol
export function SafeLink({ href, children }) {
  const safe = sanitizeUrl(href);
  return <a href={safe} rel="noopener noreferrer">{children}</a>;
}
👥 Responsible
Frontend Developer
🔒 Secure JSX Patterns
  • JSX {expression} auto-escapes — always use it
  • Never use eval() or new Function() with user data
  • Validate href starts with https:// or mailto:
  • Add rel="noopener noreferrer" to all target="_blank"
  • Never pass user input to dangerouslySetInnerHTML without DOMPurify
✅ Verification
# Run automated XSS scan on the built SPA $ npx retire --path dist/ $ npx eslint src/ --rule '{"no-script-url": "error"}' # Manual: render an XSS payload in any rich-text field # Payload: <img src=x onerror="alert(document.cookie)"> # Expected: image tag stripped, alert NEVER fires # Check for dangerouslySetInnerHTML in codebase without DOMPurify $ grep -r "dangerouslySetInnerHTML" src/ | grep -v "sanitize\|DOMPurify" → (empty — all usages are wrapped with sanitization)
📋 VAPT Closure Statement
🔒
Audit Closure
All usages of dangerouslySetInnerHTML have been wrapped with DOMPurify sanitization using a strict allowlist of HTML tags and attributes. JavaScript URL schemes in href attributes are blocked via a URL validation utility. All external links enforce rel="noopener noreferrer". An ESLint rule prohibits javascript: URL patterns. DOMPurify post-sanitize hook confirms zero script injection in test payloads.
02
Input Validation with React Hook Form + Zod
HIGH Frontend react-hook-formzod@hookform/resolvers
⚠ Issue
React forms without validation allow malformed data, oversized payloads, script injection, and invalid types to reach the API layer. Client-side validation alone is insufficient but is an essential first defense layer.
🔥 Risk
Injection attacks via form fields, business logic bypass via unexpected data types, and DoS via oversized inputs that overwhelm backend processing.
🔧 Fix — RHF + Zod with security-focused schema
Terminal
$ npm install react-hook-form zod @hookform/resolvers
src/schemas/auth.schema.js
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string()
    .min(1, 'Email is required')
    .email('Must be a valid email address')
    .max(254, 'Email too long')
    .toLowerCase()
    .trim(),

  password: z.string()
    .min(8,  'Minimum 8 characters')
    .max(128, 'Password too long'),
});

export const profileSchema = z.object({
  name: z.string()
    .min(1).max(100)
    .regex(/^[\p{L}\p{M} '-]+$/u, 'Name contains invalid characters'),

  bio: z.string()
    .max(500, 'Bio limited to 500 characters')
    .optional(),

  website: z.string()
    .url('Must be a valid URL')
    .startsWith('https://', 'Only HTTPS URLs allowed')
    .optional(),

  age: z.number()
    .int().min(13).max(120)
    .optional(),
});
src/components/LoginForm.jsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema } from '../schemas/auth.schema';

export function LoginForm({ onSubmit }) {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(loginSchema),
    mode: 'onBlur',          // validate on blur, not on every keystroke
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input
        type="email"
        autoComplete="email"
        aria-invalid={!!errors.email}
        {...register('email')}
      />
      {errors.email && <span role="alert">{errors.email.message}</span>}

      <input
        type="password"
        autoComplete="current-password"
        aria-invalid={!!errors.password}
        {...register('password')}
      />
      {errors.password && <span role="alert">{errors.password.message}</span>}

      <button type="submit">Login</button>
    </form>
  );
}
👥 Responsible
Frontend Developer
⚠ Important Note
Client-side validation is a UX layer — never trust it for security. Always validate again on the backend (Node.js/Express). Attackers can bypass browser validation entirely.
✅ Verification
# Test validation fires correctly in browser # Submit form with email="<script>" → should show validation error # Submit with password="short" → "Minimum 8 characters" # Verify schema via unit tests $ npx vitest src/schemas/auth.schema.test.js → All schema validation tests pass # Check Zod errors never expose internal logic to users # Error messages should be user-friendly, not technical stack traces
📋 VAPT Closure Statement
🔒
Audit Closure
All React forms use React Hook Form with Zod schema validation. Schemas enforce type correctness, length limits, format validation (email, URL), and character allowlists. Validation fires on blur to provide immediate feedback. Form fields declare appropriate autocomplete attributes and ARIA roles for accessibility. Note: all schemas are mirrored in backend validation — client-side validation is defense-in-depth only.
Auth & Token Security
03
Secure Token Storage — Never localStorage
CRITICAL Frontend + Backend httpOnly cookieaxios
⚠ Issue
Storing JWT access tokens in localStorage or sessionStorage makes them accessible to any JavaScript on the page. A single XSS vulnerability — even in a third-party script — can silently exfiltrate all tokens.
🔥 Risk
Complete session hijacking via XSS token theft from localStorage. Tokens stolen this way enable full account takeover lasting until token expiry — potentially weeks if refresh tokens are also stolen.
🔧 Fix — Use httpOnly cookies (set by backend, read by browser automatically)
src/api/client.js — Axios with cookie credentials
import axios from 'axios';

// ─────────────────────────────────────────────────────────────
// ✗ NEVER — token theft via XSS is possible here:
//   localStorage.setItem('token', jwt)
//   axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`
//
// ✓ ALWAYS — httpOnly cookies sent automatically by browser:
// ─────────────────────────────────────────────────────────────
const api = axios.create({
  baseURL: '/api',
  withCredentials: true,   // sends httpOnly cookie on every request
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',  // helps CSRF detection
  },
});

export default api;
src/context/AuthContext.jsx — Token-free auth state
import { createContext, useContext, useState, useEffect } from 'react'; import api from '../api/client'; const AuthContext = createContext(null); export function AuthProvider({ children }) { // ✓ Store only non-sensitive user info in state (not the token!) const [user, setUser] = useState(null); // { id, name, role } only const [loading, setLoading] = useState(true); // Restore session on mount by calling /api/auth/me // Cookie is sent automatically — no token needed in JS memory useEffect(() => { api.get('/auth/me') .then(r => setUser(r.data.user)) .catch(() => setUser(null)) .finally(() => setLoading(false)); }, []); const login = async (credentials) => { const { data } = await api.post('/auth/login', credentials); // Backend sets httpOnly cookie — we only store user profile in state setUser(data.user); return data.user; }; const logout = async () => { await api.post('/auth/logout'); // backend clears cookie setUser(null); }; return ( <AuthContext.Provider value={{ user, login, logout, loading }}> {!loading && children} </AuthContext.Provider> ); } export const useAuth = () => useContext(AuthContext);
👥 Responsible
Frontend DeveloperBackend Developer
📊 Comparison
StorageXSS Safe?CSRF RiskVerdict
localStorage❌ NoLowAvoid
sessionStorage❌ NoLowAvoid
JS memory (useState)✅ YesLowOK (volatile)
httpOnly Cookie✅ YesAdd CSRF token✅ Recommended
✅ Verification
# In browser DevTools console — token MUST NOT be visible > localStorage.getItem('token') → null > document.cookie → "" or only non-sensitive cookies (httpOnly NOT visible here) # Check network tab — cookie should be in request headers automatically # GET /api/auth/me → Request Headers → Cookie: accessToken=... ✓ # Confirm httpOnly flag in Set-Cookie response header $ curl -I -X POST https://your-app.com/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"user@test.com","password":"pass"}' → set-cookie: accessToken=...; HttpOnly; Secure; SameSite=Strict
📋 VAPT Closure Statement
🔒
Audit Closure
JWT access tokens are stored exclusively in HttpOnly/Secure/SameSite=Strict cookies set by the backend — never in localStorage, sessionStorage, or accessible JavaScript memory. The React application uses withCredentials: true so cookies are sent automatically. Only non-sensitive user profile data (id, name, role) is held in React state. Verification confirms tokens are not accessible via document.cookie or browser storage APIs.
04
Protected Routes & Role-Based Auth Guards
HIGH Frontend react-router-domv6
⚠ Issue
Without route guards, unauthenticated users can navigate directly to protected URLs (e.g., /admin/dashboard), and lower-privilege users can access admin-only views by knowing the URL pattern.
🔥 Risk
Unauthorized access to admin UI, privilege escalation via URL manipulation, and exposure of sensitive data rendered in components before auth check completes.
🔧 Fix — ProtectedRoute component (React Router v6)
src/components/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

/**
 * ProtectedRoute — redirects unauthenticated users to /login
 * and blocks insufficient-role users with a 403 page.
 */
export function ProtectedRoute({ children, requiredRoles = [] }) {
  const { user, loading } = useAuth();
  const location = useLocation();

  // Don't render anything while checking auth (prevents flash)
  if (loading) return <div aria-busy="true">Checking session…</div>;

  // Not authenticated → redirect to login, save intended path
  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  // Authenticated but insufficient role → 403 page
  if (requiredRoles.length > 0 && !requiredRoles.includes(user.role)) {
    return <Navigate to="/403" replace />;
  }

  return children;
}

// Usage in router:
// <Route path="/admin" element={
//   <ProtectedRoute requiredRoles={['admin']}>
//     <AdminDashboard />
//   </ProtectedRoute>
// } />
src/router/AppRouter.jsx — Route structure
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ProtectedRoute } from '../components/ProtectedRoute';

export function AppRouter() {
  return (
    <BrowserRouter>
      <Routes>
        <!-- Public routes -->
        <Route path="/"      element={<Home />} />
        <Route path="/login" element={<Login />} />
        <Route path="/403"   element={<Forbidden />} />

        <!-- Auth required: any logged-in user -->
        <Route path="/profile" element={
          <ProtectedRoute>
            <UserProfile />
          </ProtectedRoute>
        } />

        <!-- Admin only -->
        <Route path="/admin/*" element={
          <ProtectedRoute requiredRoles={['admin']}>
            <AdminLayout />
          </ProtectedRoute>
        } />

        <!-- Catch-all: 404 -->
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}
👥 Responsible
Frontend Developer
⚠ Critical Reminder
Route guards are UX-level protection only. Always enforce role-based access control on the backend API. A user can disable JS and hit your API directly, bypassing all frontend guards.
✅ Verification
# Navigate to /admin while logged out → Should redirect to /login with ?from=/admin # Log in as regular user, navigate to /admin → Should redirect to /403 # Verify no sensitive data rendered before auth check # Check React DevTools — ProtectedRoute shows loading state, not content
📋 VAPT Closure Statement
🔒
Audit Closure
All authenticated routes are wrapped with a ProtectedRoute component that validates session state before rendering. Unauthenticated users are redirected to /login with the intended path preserved for post-login redirect. Role-based access control prevents lower-privilege users from accessing admin routes, redirecting to a dedicated 403 page. A loading state prevents any content flash before auth resolution.
05
Secure API Calls with Axios Interceptors
HIGH Frontend axiosinterceptorstoken refresh
⚠ Issue
Without interceptors, expired token 401 errors are unhandled (user sees broken UI), CSRF tokens are manually included per-request (error-prone), and failed requests cascade silently without re-auth.
🔥 Risk
Session continuity failure exposing users to state loss, missing CSRF headers on requests leaving forms unprotected, and silent API errors that hide security failures from monitoring.
🔧 Fix — Hardened Axios with interceptors
src/api/client.js — Full interceptor setup
import axios from 'axios';

const api = axios.create({
  baseURL:         '/api',
  withCredentials: true,
  timeout:         15000,
});

// ─── Request interceptor ─────────────────────────────────────
api.interceptors.request.use(async (config) => {
  // Fetch CSRF token and attach to every mutating request
  if (['post','put','patch','delete'].includes(config.method)) {
    try {
      const { data } = await axios.get('/api/csrf-token', { withCredentials: true });
      config.headers['X-CSRF-Token'] = data.csrfToken;
    } catch (e) {
      console.warn('CSRF token fetch failed');
    }
  }
  // Never attach tokens from localStorage — cookie handles auth
  return config;
});

// ─── Response interceptor ────────────────────────────────────
let isRefreshing = false;
let refreshQueue = [];

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;

    // Handle 401 — attempt token refresh once
    if (error.response?.status === 401 && !original._retried) {
      if (isRefreshing) {
        return new Promise((res, rej) =>
          refreshQueue.push({ resolve: res, reject: rej })
        ).then(() => api(original));
      }
      original._retried = true;
      isRefreshing = true;
      try {
        await axios.post('/api/auth/refresh', {}, { withCredentials: true });
        refreshQueue.forEach(({ resolve }) => resolve());
        refreshQueue = [];
        return api(original);
      } catch (refreshErr) {
        refreshQueue.forEach(({ reject }) => reject(refreshErr));
        refreshQueue = [];
        // Redirect to login on refresh failure
        window.location.assign('/login?session=expired');
      } finally {
        isRefreshing = false;
      }
    }

    // Sanitize error before logging — never log raw responses
    const safeError = {
      status:  error.response?.status,
      message: error.response?.data?.error || error.message,
      url:     original.url,
    };
    console.error('API Error:', safeError);

    return Promise.reject(safeError);
  }
);

export default api;
👥 Responsible
Frontend DeveloperBackend Developer
✅ Verification
# Force a 401 — simulate expired token: # Clear the backend session / expire the cookie manually # Make a protected API call from React app → Should silently refresh and retry (user sees no error) # Check CSRF header is sent on POST requests # Browser Network tab → POST /api/data → Request Headers: → X-CSRF-Token: (value present) # Verify no Authorization header with Bearer token → Authorization header should NOT appear in requests
📋 VAPT Closure Statement
🔒
Audit Closure
Axios interceptors centrally enforce CSRF token attachment on all mutating requests, handle 401 token refresh with queue management to prevent race conditions, and redirect to login on refresh failure. No Authorization headers with Bearer tokens are set — authentication relies exclusively on HttpOnly cookies. API error responses are sanitized before logging to prevent sensitive backend data from appearing in browser console.
Data & State Security
06
State Management Security (Redux / Context)
MEDIUM Frontend reduxredux-persistcontext
⚠ Issue
Storing sensitive data (passwords, full credit card numbers, SSNs, tokens) in Redux state persists them to localStorage via redux-persist and makes them visible in Redux DevTools — accessible to any browser extension.
🔥 Risk
Sensitive PII and credentials persisted to localStorage via redux-persist, XSS access to full Redux state tree, and malicious browser extensions reading DevTools state.
🔧 Fix — Whitelist only safe state for persistence + block sensitive state
src/store/store.js — Safe redux-persist config
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
import storage from 'redux-persist/lib/storage';

// ✓ WHITELIST only non-sensitive slices for persistence
const persistConfig = {
  key: 'root',
  storage,
  whitelist: ['ui', 'preferences'],   // ONLY these slices persist
  // ✗ NEVER whitelist: auth, payment, userData with PII, tokens
};

const rootReducer = combineReducers({
  ui:          uiReducer,          // ✓ Safe to persist (theme, locale)
  preferences: prefReducer,        // ✓ Safe (layout settings)
  auth:        authReducer,        // ✗ NOT persisted — re-fetch on mount
  user:        userReducer,        // ✗ NOT persisted — minimal in-memory
});

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  devTools: process.env.NODE_ENV !== 'production',  // Disable DevTools in prod
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});
src/store/authSlice.js — Never store tokens in Redux
import { createSlice } from '@reduxjs/toolkit';

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user:    null,   // Only: { id, name, role, email } — no passwords!
    // ✗ NEVER: token, password, refreshToken, creditCard
  },
  reducers: {
    setUser:   (state, { payload }) => { state.user = payload },
    clearUser: (state) => { state.user = null },
  },
});
👥 Responsible
Frontend Developer
✅ Verification
# Check localStorage for sensitive data > JSON.parse(localStorage.getItem('persist:root')) → Should only contain 'ui' and 'preferences' — no auth, no tokens # Open Redux DevTools in production build → DevTools panel should be empty / disabled in production # Search codebase for sensitive data in state $ grep -r "password\|token\|secret\|creditCard" src/store/ → Only references that are correctly NOT stored
📋 VAPT Closure Statement
🔒
Audit Closure
Redux state persistence is configured with an explicit whitelist containing only non-sensitive UI and preferences slices. Authentication state, user PII, and all token-related data are excluded from persistence. Redux DevTools is disabled in production builds via the devTools flag. State slices confirmed to contain no passwords, tokens, or sensitive credentials.
07
Environment Variables — Never Expose Secrets in Frontend
CRITICAL Build .envVITE_REACT_APP_
⚠ Issue
Any environment variable prefixed with VITE_ or REACT_APP_ is inlined into the JavaScript bundle and readable by anyone who inspects the built files. Database passwords, private API keys, and JWT secrets must never use these prefixes.
🔥 Risk
Secret API keys and credentials embedded in production JS bundle are readable in browser DevTools (Sources tab → search → find any string). GitHub scanners, wayback machine, and CDN caches can expose them permanently.
🔧 Fix — Safe vs Unsafe variable classification
.env.production — What belongs in the frontend build
# ✓ SAFE — public, non-sensitive, read-only config
VITE_API_BASE_URL=https://api.your-app.com
VITE_APP_NAME=YourApp
VITE_GOOGLE_MAPS_PUBLIC_KEY=AIza...    # restrict by HTTP referrer in GCP!
VITE_SENTRY_DSN=https://public@sentry.io/123

# ✗ NEVER in frontend .env (goes in backend only!)
# VITE_DB_PASSWORD=secret        ← exposed in bundle
# VITE_JWT_SECRET=mysecret       ← exposed in bundle
# VITE_STRIPE_SECRET_KEY=sk_live ← exposed in bundle
# VITE_SMTP_PASSWORD=pass        ← exposed in bundle
src/config/env.js — Validate required env vars at build time
// Validate all required public env vars are set at startup const required = ['VITE_API_BASE_URL']; required.forEach((key) => { if (!import.meta.env[key]) { throw new Error(`Missing required env var: ${key}`); } }); export const config = { apiUrl: import.meta.env.VITE_API_BASE_URL, appName: import.meta.env.VITE_APP_NAME || 'App', };
.gitignore — Ensure .env files never reach Git
# All local .env files .env .env.local .env.*.local .env.production.local dist/ # never commit the build output
👥 Responsible
Frontend DeveloperDevOps
✅ Verification
# Search the production bundle for secrets $ grep -r "sk_live\|password\|secret\|private_key" dist/assets/ → (empty — no secrets in bundle) # Check what's exposed via import.meta.env $ grep -r "import.meta.env\." src/ | grep -v 'VITE_' → (empty — only VITE_ prefixed vars used in frontend) # Audit all VITE_ variables for sensitivity $ grep -r "VITE_" .env.production → Each listed variable should be public/non-sensitive
📋 VAPT Closure Statement
🔒
Audit Closure
All environment variables exposed to the React bundle (via VITE_ prefix) have been audited and contain only public, non-sensitive configuration. Database credentials, JWT secrets, private API keys, and SMTP passwords are confirmed absent from frontend configuration. A grep scan of the production bundle confirms no secret patterns. .env files are excluded from version control via .gitignore.
08
Sensitive Data in DevTools & Console Logs
MEDIUM Frontend React DevToolsconsole.log
⚠ Issue
React DevTools exposes every component's props and state to browser extensions. console.log() of API responses, user objects, or form data leaks sensitive information to anyone with DevTools open — and to browser extensions reading console output.
🔥 Risk
Sensitive user PII, API response data, and internal application state exposed to browser extensions, shared screens, shoulder-surfing, and JavaScript console interception.
🔧 Fix — Strip console logs in production + sanitize DevTools props
vite.config.js — Auto-remove console.log in production
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig(({ mode }) => ({ plugins: [react()], build: { sourcemap: mode !== 'production', // disable source maps in prod minify: 'terser', terserOptions: { compress: { drop_console: mode === 'production', // ✓ removes all console.* drop_debugger: true, pure_funcs: ['console.log', 'console.debug', 'console.info'], }, }, }, }));
src/components/UserCard.jsx — Never pass raw PII as visible props
// ✗ Avoid — full user object visible in React DevTools function UserCard({ user }) { // user includes SSN, dob, full address... return <div>{user.name}</div>; } // ✓ Destructure — pass only what's needed function UserCard({ name, avatarUrl, role }) { // minimal props return <div>{name}</div>; } // ✓ For debugging — use a logger that respects NODE_ENV const log = process.env.NODE_ENV === 'development' ? console.log : () => {}; // no-op in production
👥 Responsible
Frontend Developer
✅ Verification
# Open production build in browser, open DevTools console → No console.log, console.debug, or console.info output visible # Check for console statements in built bundle $ grep -c "console\.log" dist/assets/index-*.js → 0 # Inspect React DevTools — UserCard component → Props should show: name, avatarUrl, role (not: ssn, fullAddress, dob)
📋 VAPT Closure Statement
🔒
Audit Closure
All console.log, console.debug, and console.info statements are removed from the production bundle via Terser's drop_console option. Components follow the principle of minimal props — only required display data is passed, not full user objects. Verified by inspecting the production build: zero console statements and no PII visible in React DevTools component tree.
Build & Deploy Security
09
Disable Source Maps in Production
HIGH Build Config vite.config.js.env.productionnginx.conf
⚠ Issue
Production source maps (.js.map files) expose your full, unminified source code — including comments, variable names, internal logic, API endpoint patterns, and business logic — to anyone who opens DevTools.
🔥 Risk
Full source code reverse engineering, exposure of internal API patterns and endpoint names, business logic disclosure, hardcoded strings and comments with sensitive context, and finding of hidden features/flags.
🔧 Fix — Disable source maps for Vite & CRA + block via Nginx
vite.config.js (Vite)
export default defineConfig(({ mode }) => ({ build: { sourcemap: mode === 'development', // false in production rollupOptions: { output: { // Obfuscate chunk names (don't reveal module structure) chunkFileNames: 'assets/[hash].js', entryFileNames: 'assets/[hash].js', assetFileNames: 'assets/[hash].[ext]', }, }, }, }));
.env.production (CRA)
# Create React App — disable source maps in production build GENERATE_SOURCEMAP=false
/etc/nginx/sites-enabled/your-app.conf — Block .map files if they exist
# Block access to source map files at server level (defense in depth) location ~* \.map$ { deny all; return 404; access_log off; log_not_found off; }
👥 Responsible
Frontend DeveloperDevOps
✅ Verification
# Check no .map files in production build $ ls dist/assets/*.map 2>/dev/null | wc -l → 0 # Check no SourceMappingURL comment in JS bundle $ grep "sourceMappingURL" dist/assets/index-*.js → (empty) # Attempt to access a .map URL via Nginx $ curl -I https://your-app.com/assets/index-abc.js.map → HTTP/2 404 # Open DevTools → Sources → should show minified code only, not original
📋 VAPT Closure Statement
🔒
Audit Closure
Source map generation is disabled in the production Vite build (sourcemap: false) and via GENERATE_SOURCEMAP=false for CRA. Chunk filenames use content hashes without module names. Nginx blocks .map file access at the server level as defense-in-depth. Production bundle verified to contain no //# sourceMappingURL references. Reverse engineering of source code is not possible from the deployed bundle.
10
Vite / CRA Build Hardening
HIGH Build Config vite.config.jsSRIterser
⚠ Issue
Default Vite/CRA builds may include development tooling, unminified chunks with readable variable names, missing Subresource Integrity hashes on CDN assets, and no chunk size limits that could indicate bundled secrets.
🔥 Risk
CDN asset tampering (no SRI), large bundle chunks indicating bundled secrets, readable variable names aiding reverse engineering, and development utilities shipped to production.
🔧 Fix — Production-hardened vite.config.js
vite.config.js — Complete security hardening
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig(({ mode }) => ({ plugins: [react()], build: { sourcemap: false, // no source maps in prod minify: 'terser', target: 'es2018', // avoid legacy polyfills (attack surface) chunkSizeWarningLimit: 500, // warn on large chunks terserOptions: { compress: { drop_console: true, drop_debugger: true, passes: 2, // double minification pass }, mangle: { toplevel: true, // mangle top-level variable names }, format: { comments: false, // strip all comments (including version hints) }, }, rollupOptions: { output: { // Separate vendor chunks — easier to audit manualChunks(id) { if (id.includes('node_modules')) { return 'vendor'; } }, // Hash-only filenames — no module names exposed chunkFileNames: '[hash].js', entryFileNames: '[hash].js', assetFileNames: '[hash].[ext]', }, }, }, // Prevent accidental exposure of env vars envPrefix: ['VITE_'], // only VITE_ vars exposed (default) }));
👥 Responsible
Frontend DeveloperDevOps
✅ Verification
# Run production build and analyze $ npm run build $ npx vite-bundle-visualizer # or webpack-bundle-analyzer for CRA # Check no comments in bundle $ grep -c "/\*\|//" dist/assets/*.js → 0 (all comments stripped) # Verify chunk sizes are reasonable (no huge unexplained chunks) $ ls -lh dist/assets/*.js | sort -k5 -rh | head -5 → Largest chunk should be vendor.js (~200-400KB) — not 10MB+
📋 VAPT Closure Statement
🔒
Audit Closure
Vite production build is hardened: Terser minification with double-pass compression, top-level variable name mangling, all comments stripped (preventing version hints), source maps disabled, and console statements removed. Chunk filenames use content hashes only. Bundle analysis confirms no unexpected large chunks indicating bundled secrets. All comments (including version annotations) are absent from the production bundle.
11
Frontend Dependency Security
HIGH DevSecOps npm auditsnyksocket.dev
⚠ Issue
React projects have hundreds of transitive dependencies. CVEs in widely-used packages (e.g., lodash, axios, react-scripts, webpack) are actively exploited. Malicious packages via typosquatting target common React package names.
🔥 Risk
XSS via vulnerable React component libraries, prototype pollution via lodash CVEs, supply chain attacks via malicious npm packages, and ReDoS via vulnerable regex packages in build tools.
🔧 Fix — Automated security scanning in CI
Terminal — Audit and remediate
# Run npm security audit $ npm audit $ npm audit --audit-level=high # fail CI on high/critical $ npm audit fix # auto-fix non-breaking # Use Socket.dev for supply-chain analysis (beyond CVEs) $ npx @socket.dev/cli check # Check for typosquatted packages (common React targets) $ npx installed-check # flags malicious indicators # Pin exact versions — no ranges in production $ npm install --save-exact react react-dom axios # Use lockfile for reproducible installs $ npm ci # always in CI/CD (uses package-lock.json exactly)
.github/workflows/security.yml — Automated weekly scan
name: Security Audit on: schedule: - cron: '0 8 * * 1' # Every Monday 8 AM push: branches: [main] jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npm audit --audit-level=high - run: npx snyk test --severity-threshold=high env: { SNYK_TOKEN: '${{ secrets.SNYK_TOKEN }}' }
👥 Responsible
Frontend DeveloperDevOps
✅ Verification
$ npm audit --audit-level=high → found 0 vulnerabilities (high/critical) $ npx snyk test → Tested X dependencies. No issues found. # Verify lockfile is committed $ git status package-lock.json → (unmodified / tracked) # Verify React version is latest stable $ npm view react version $ node -e "console.log(require('./package.json').dependencies.react)" → Should match latest stable React
📋 VAPT Closure Statement
🔒
Audit Closure
Automated dependency security scanning is integrated into the CI/CD pipeline via npm audit (high severity threshold) and Snyk, running on every push to main and weekly scheduled scans. All production dependencies are pinned to exact versions. npm ci is used in deployments for reproducible builds from the committed lockfile. Current scan result: 0 high or critical vulnerabilities. Package-lock.json is committed and verified.
Network & Headers
12
Content Security Policy for React SPAs
HIGH Server + Build nginx.confCSPindex.html
⚠ Issue
React SPAs without a CSP allow any injected script to execute, external resources to be loaded, and data to be exfiltrated to attacker-controlled domains. unsafe-inline in CSP negates XSS protection — React's inline event handlers require careful nonce-based CSP.
🔥 Risk
XSS attacks load remote malicious scripts, data exfiltration to external domains via injected fetch(), and Magecart-style skimmer attacks on forms via third-party script compromise.
🔧 Fix — CSP for React SPA via Nginx
/etc/nginx/sites-enabled/your-app.conf — CSP header for SPA
# ─── React SPA served from Nginx ──────────────────────────── server { listen 443 ssl http2; server_name your-app.com; root /var/www/your-app/dist; index index.html; # SPA routing — serve index.html for all routes location / { try_files $uri $uri/ /index.html; } # ─── Security headers ──────────────────────────────────── add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: blob:; connect-src 'self' https://api.your-app.com wss://api.your-app.com; media-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always; # Cache static assets (not HTML) location ~* \.(js|css|png|jpg|ico|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; } }
public/_headers (Netlify/Vercel/Cloudflare Pages)
# Netlify _headers file (place in public/ or project root) /* Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; connect-src 'self' https://api.your-app.com; frame-src 'none'; object-src 'none'; base-uri 'self'; X-Frame-Options: DENY X-Content-Type-Options: nosniff Strict-Transport-Security: max-age=31536000; includeSubDomains; preload Referrer-Policy: strict-origin-when-cross-origin
👥 Responsible
DevOps / InfraFrontend Developer
🔄 Restart
⟳ systemctl reload nginx
✅ Verification
# Check CSP header is present $ curl -I https://your-app.com/ | grep -i content-security-policy → content-security-policy: default-src 'self'; ... # Test CSP blocks inline scripts (if unsafe-inline not set) # In browser console: eval("alert(1)") → blocked by CSP # Grade the CSP policy $ curl -s "https://csp-evaluator.withgoogle.com/..." (use online tool) → No high-severity CSP bypasses found # Test external script injection is blocked # Inject: <script src="https://evil.com/steal.js"></script> → Browser console: Refused to load the script ... violates CSP
📋 VAPT Closure Statement
🔒
Audit Closure
A Content Security Policy is enforced via Nginx response headers restricting script execution to 'self', blocking inline scripts without nonces, restricting connect-src to approved API domains, and disabling framing (frame-src: none). base-uri and form-action are restricted to 'self'. Policy has been validated via Google's CSP Evaluator with no high-severity bypass findings. upgrade-insecure-requests enforces HTTPS for all sub-resources.
13
Third-Party Script Security (SRI)
HIGH Frontend + Build integritycrossoriginSRI
⚠ Issue
Loading JavaScript or CSS from CDNs without Subresource Integrity (SRI) hashes means a compromised CDN server can serve malicious code that executes in your app's origin context — a classic Magecart attack vector.
🔥 Risk
CDN supply-chain attack — if the CDN is compromised, the attacker's JavaScript runs with full access to your DOM, cookies, and API. Affects millions of users simultaneously across all visitors.
🔧 Fix — Add SRI hashes to all external resources
index.html — SRI for CDN-loaded resources
<!-- ✓ CORRECT: SRI hash + crossorigin for CDN scripts --> <script src="https://cdn.example.com/library@2.1.0/lib.min.js" integrity="sha384-ABC123XYZ...your-hash-here" crossorigin="anonymous" ></script> <!-- Generate SRI hash: --> <!-- $ curl https://cdn.example.com/lib.min.js | openssl dgst -sha384 -binary | openssl base64 -A --> <!-- Or use: https://www.srihash.org --> <link rel="stylesheet" href="https://cdn.example.com/styles.min.css" integrity="sha384-DEF456ABC...your-hash-here" crossorigin="anonymous" /> <!-- ✗ WRONG: No integrity check → CDN compromise = your app compromised --> <!-- <script src="https://cdn.example.com/lib.min.js"></script> -->
Terminal — Generate SRI hash for any resource
# Generate SHA-384 SRI hash $ curl -s https://cdn.example.com/library.min.js \ | openssl dgst -sha384 -binary \ | openssl base64 -A → sha384-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # Or use the npx tool $ npx sri-hash https://cdn.example.com/library.min.js

💡 Best practice: bundle dependencies via npm rather than CDN links. This avoids CDN risk entirely and allows tree-shaking for smaller bundles.

👥 Responsible
Frontend Developer
✅ Verification
# Find all external script/link tags without integrity attribute $ grep -n "<script\|<link" public/index.html | grep -v "integrity=" | grep "http" → (empty — all external resources have SRI) # Verify SRI works — modify CDN hash intentionally and load page # Browser console should show: "Failed to find a valid digest..." → Resource blocked by browser SRI check
📋 VAPT Closure Statement
🔒
Audit Closure
All external scripts and stylesheets loaded from CDNs include SHA-384 Subresource Integrity hashes and the crossorigin="anonymous" attribute. Browser will block loading of any CDN asset whose content differs from the pinned hash. Where possible, third-party libraries have been bundled via npm to eliminate CDN dependency. An automated check confirms no external resources lack SRI attributes.
14
Clickjacking & iframe Framing Prevention
MEDIUM Server + Frontend X-Frame-Optionsframe-ancestors
⚠ Issue
If a React SPA can be embedded in an iframe on attacker-controlled pages, users can be tricked into clicking invisible UI elements (clickjacking) — triggering actions like account deletion, fund transfers, or OAuth grants.
🔥 Risk
Clickjacking attacks tricking users into performing unintended authenticated actions — password changes, privacy setting changes, OAuth permission grants, financial transactions.
🔧 Fix — Frame busting at server & React level
/etc/nginx/sites-enabled/your-app.conf
# Prevent framing via legacy header (IE/older browsers) add_header X-Frame-Options "DENY" always; # Modern: CSP frame-ancestors (overrides X-Frame-Options) # Already included in CSP header above: # ... frame-ancestors 'self' ... # Use 'none' if you never need to frame your own app: # ... frame-ancestors 'none' ...
src/utils/frameProtection.js — React-level bust (defense-in-depth)
// Frame-busting script — runs before React renders // Add to public/index.html or import in main.jsx if (window.self !== window.top) { // We're inside an iframe we didn't expect window.top.location.href = window.location.href; // Or show a warning instead of redirecting: // document.body.innerHTML = '<h1>Security Error: framing not allowed</h1>'; } // Alternatively in main.jsx: if (window.self !== window.top) { throw new Error('Application cannot run inside an iframe'); }
👥 Responsible
DevOps / Nginx AdminFrontend Developer
✅ Verification
# Verify X-Frame-Options header is set $ curl -I https://your-app.com/ | grep -i x-frame-options → x-frame-options: DENY # Try embedding your app in an iframe # In any HTML page: <iframe src="https://your-app.com"></iframe> → Browser shows: "your-app.com refused to connect" # Check CSP frame-ancestors $ curl -I https://your-app.com/ | grep content-security-policy | grep "frame-ancestors" → ... frame-ancestors 'none'; ...
📋 VAPT Closure Statement
🔒
Audit Closure
Clickjacking protection is implemented via X-Frame-Options: DENY and the CSP frame-ancestors 'none' directive (which takes precedence in modern browsers). A JavaScript frame-busting guard in the React app provides defense-in-depth. Browser test confirms the application refuses to load in any iframe context.
Audit & Monitoring
15
CORS from the React SPA Perspective
MEDIUM Frontend + Backend vite proxywithCredentials
⚠ Issue
React apps in development often use proxy or allow all origins — and these insecure settings leak to production. Misconfiguring withCredentials: true with a wildcard CORS backend creates CSRF-via-CORS vulnerabilities.
🔥 Risk
CSRF via CORS — attacker site makes credentialed cross-origin requests. Credential leakage if withCredentials: true is paired with Access-Control-Allow-Origin: * (browser actually blocks this, but misconfigured backends may fall back to echoing origin).
🔧 Fix — Development proxy + production CORS checklist
vite.config.js — Dev proxy (prevents CORS issues in dev, not prod)
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, secure: false, // rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, });
Production CORS verification checklist
# ✓ Backend must NEVER set these in production: # Access-Control-Allow-Origin: * + Access-Control-Allow-Credentials: true # (browser blocks it, but some proxies may accept it) # ✓ Correct production CORS headers from backend: # Access-Control-Allow-Origin: https://your-app.com # Access-Control-Allow-Credentials: true # Access-Control-Allow-Methods: GET, POST, PUT, DELETE # Access-Control-Allow-Headers: Content-Type, X-CSRF-Token # Test from unauthorized origin: $ curl -H "Origin: https://evil.com" \ -H "Cookie: session=valid" \ https://api.your-app.com/user/profile # Must NOT return Access-Control-Allow-Origin: https://evil.com
👥 Responsible
Frontend DeveloperBackend Developer
📋 VAPT Closure Statement
🔒
Audit Closure
The Vite development proxy is used only in development and does not affect production CORS behavior. The production backend enforces an origin whitelist (Access-Control-Allow-Origin: https://your-app.com) and never uses wildcard with credentials. Cross-origin requests from unauthorized origins are confirmed to receive no CORS headers, verified via curl with forged Origin headers.
16
React Security Audit Checklist & Automated Linting
MEDIUM DevSecOps eslint-plugin-securityOWASP ZAP
⚠ Issue
Without automated security linting, insecure code patterns (unsafe HTML injection, hardcoded secrets, eval usage) are only caught in manual review — which is inconsistent and error-prone.
🔥 Risk
Security regressions introduced via new code commits, hardcoded API keys caught only after exposure, and accumulated technical security debt from un-reviewed insecure patterns.
🔧 Fix — Security ESLint rules + OWASP ZAP scan
Terminal — Install security linting plugins
$ npm install --save-dev \ eslint-plugin-security \ eslint-plugin-no-secrets \ eslint-plugin-react \ eslint-plugin-jsx-a11y
.eslintrc.json — Security rules
{ "plugins": ["security", "no-secrets", "react"], "extends": [ "plugin:security/recommended", "plugin:react/recommended" ], "rules": { "no-eval": "error", "no-implied-eval": "error", "no-new-func": "error", "no-script-url": "error", "security/detect-eval-with-expression": "error", "security/detect-non-literal-regexp": "warn", "security/detect-object-injection": "warn", "no-secrets/no-secrets": ["error", { "tolerance": 4.5 }], // Enforce DOMPurify usage check "react/no-danger": "error", "react/no-danger-with-children": "error" } }
Terminal — Run OWASP ZAP on the built SPA
# Run OWASP ZAP baseline scan (Docker) $ docker run -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \ -t https://your-app.com \ -g gen.conf \ -r zap-report.html # Full active scan (more thorough, use in staging only) $ docker run -t ghcr.io/zaproxy/zaproxy:stable zap-full-scan.py \ -t https://staging.your-app.com \ -r zap-full-report.html
👥 Responsible
Frontend DeveloperDevOps
🗓 Audit Schedule
  • Every commit: ESLint security rules in CI
  • Every build: npm audit --audit-level=high
  • Weekly: Snyk dependency scan
  • Monthly: OWASP ZAP baseline scan on staging
  • Quarterly: Manual code review of auth & data flows
✅ Verification
# Run ESLint with security rules $ npx eslint src/ --ext .jsx,.js,.tsx,.ts → 0 errors, 0 warnings (security rules) # Check no-secrets catches hardcoded keys $ echo 'const key = "AKIAIOSFODNN7EXAMPLE"' > test.js && npx eslint test.js → Error: no-secrets/no-secrets: Found a string with entropy... # OWASP ZAP baseline — check for critical findings $ docker run ... zap-baseline.py -t https://your-app.com → PASS: 0 CRITICAL alerts
📋 VAPT Closure Statement
🔒
Audit Closure
Security-focused ESLint plugins (eslint-plugin-security, eslint-plugin-no-secrets) are integrated into the CI pipeline and run on every commit. Rules block eval(), javascript: URLs, dangerouslySetInnerHTML without sanitization, and high-entropy strings (hardcoded secrets). OWASP ZAP baseline scan reports 0 critical alerts on the production SPA. Monthly scans are scheduled and results tracked in the project security log.

⚛ React Security Controls Summary

Master VAPT remediation checklist — update status as each control is implemented and verified

#ControlSeverityCategoryKey Tool / PackageStatus
01XSS Prevention & dangerouslySetInnerHTMLCRITICALXSSDOMPurify☐ Open
02Input Validation (RHF + Zod)HIGHXSSreact-hook-form zod☐ Open
03Secure Token Storage — httpOnly CookieCRITICALAuthaxios withCredentials☐ Open
04Protected Routes & RBAC GuardsHIGHAuthreact-router-dom☐ Open
05Secure API Calls (Axios Interceptors)HIGHAuthaxios interceptors☐ Open
06State Management SecurityMEDIUMDataredux-persist whitelist☐ Open
07Environment Variables SafetyCRITICALDataVITE_ prefix audit☐ Open
08Sensitive Data in DevTools / ConsoleMEDIUMDataTerser drop_console☐ Open
09Disable Source Maps in ProductionHIGHBuildvite sourcemap:false☐ Open
10Vite / CRA Build HardeningHIGHBuildterser hash chunks☐ Open
11Frontend Dependency SecurityHIGHBuildnpm audit snyk☐ Open
12Content Security Policy (SPA)HIGHNetworknginx CSP header☐ Open
13Third-Party Script Security (SRI)HIGHNetworkintegrity attribute☐ Open
14Clickjacking & iframe PreventionMEDIUMNetworkX-Frame-Options DENY☐ Open
15CORS from SPA PerspectiveMEDIUMAuditvite proxy origin check☐ Open
16Security Audit & ESLint RulesMEDIUMAuditeslint-plugin-security☐ Open
Critical: 3 controls
High: 8 controls
Medium: 5 controls
OWASP React Security Cheatsheet · April 2026