| name | frontend-security |
| description | Frontend security best practices and vulnerability prevention. Use when asked to "security audit", "fix vulnerabilities", "secure the app", "OWASP frontend", "prevent XSS", "token storage", or "security review". |
| license | MIT |
| metadata | {"author":"pragma-frontend-security","version":"1.0","owasp_reference":"OWASP Top 10 2021 + OWASP Client-Side Security"} |
Frontend Security — Complete Knowledge Base
Security rules and patterns for web frontend applications. Covers OWASP Top 10 adapted to client-side, secure coding patterns, and anti-patterns that introduce vulnerabilities.
S1 — Cross-Site Scripting (XSS) Prevention
XSS is the #1 frontend vulnerability. Three types: Stored, Reflected, DOM-based.
NEVER Do
element.innerHTML = userInput;
<div dangerouslySetInnerHTML={{ __html: userInput }} />
this.sanitizer.bypassSecurityTrustHtml(userInput);
eval(userInput);
new Function(userInput)();
document.write(userInput);
window.location.href = userInput;
ALWAYS Do
<p>{userInput}</p> // JSX escapes by default
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
<div [innerHTML]="userContent"></div>
element.textContent = userInput;
const isValidUrl = (url: string): boolean => {
try {
const parsed = new URL(url, window.location.origin);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
};
DOMPurify Configuration (when HTML rendering is unavoidable)
import DOMPurify from 'dompurify';
const SANITIZE_CONFIG = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['rel'],
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
export const sanitizeHtml = (dirty: string): string =>
DOMPurify.sanitize(dirty, SANITIZE_CONFIG);
S2 — Authentication & Token Management
Token Storage — Decision Matrix
| Storage | XSS Vulnerable | CSRF Vulnerable | Recommendation |
|---|
localStorage | ✅ YES — JS can read it | ❌ No | NEVER for auth tokens |
sessionStorage | ✅ YES — JS can read it | ❌ No | NEVER for auth tokens |
HttpOnly Cookie | ❌ No — JS can't read it | ✅ YES (mitigable) | PREFERRED |
| Memory (variable) | ❌ No (dies on refresh) | ❌ No | Good for short-lived tokens |
NEVER Do
localStorage.setItem('token', jwt);
sessionStorage.setItem('auth_token', token);
window.location.href = `/dashboard?token=${jwt}`;
window.__AUTH_TOKEN__ = jwt;
console.log('Token:', token);
console.log('User:', JSON.stringify(user));
ALWAYS Do
const response = await fetch('/api/data', {
credentials: 'include',
});
class TokenManager {
private accessToken: string | null = null;
setToken(token: string): void {
this.accessToken = token;
this.scheduleRefresh(token);
}
getToken(): string | null {
return this.accessToken;
}
clear(): void {
this.accessToken = null;
}
private scheduleRefresh(token: string): void {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
const refreshAt = expiresIn - 60_000;
setTimeout(() => this.refresh(), Math.max(refreshAt, 0));
}
private async refresh(): Promise<void> {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const { accessToken } = await res.json();
this.setToken(accessToken);
} else {
this.clear();
window.location.href = '/login';
}
}
}
Logout Checklist
async function logout(): Promise<void> {
tokenManager.clear();
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
queryClient?.clear();
sessionStorage.clear();
window.location.href = '/login';
}
S3 — Cross-Site Request Forgery (CSRF) Prevention
When You're Vulnerable
CSRF applies when:
- Authentication via cookies (not Bearer tokens in headers)
- State-changing requests (POST, PUT, DELETE, PATCH)
- No CSRF token validation
NEVER Do
<a href="/api/delete-account">Delete Account</a>
<img src="/api/transfer?amount=1000&to=attacker" />
<form action="/api/update-profile" method="POST">
<input name="email" />
<button type="submit">Update</button>
</form>
ALWAYS Do
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/update-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken ?? '',
},
credentials: 'include',
body: JSON.stringify(data),
});
function getCsrfFromCookie(): string {
const match = document.cookie.match(/csrf=([^;]+)/);
return match?.[1] ?? '';
}
S4 — Secrets & Sensitive Data Exposure
NEVER Do
const API_KEY = 'sk-live-abc123xyz789';
const STRIPE_SECRET = 'sk_live_xxxxx';
NEXT_PUBLIC_SECRET_KEY=my-secret
VITE_SECRET_KEY=my-secret
const DB_URL = 'postgres://user:pass@host:5432/db';
const admin = { username: 'admin', password: 'admin123' };
catch (error) {
alert(`Database error: ${error.message}`);
}
ALWAYS Do
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
NEXT_PUBLIC_API_URL=https:
const data = await fetch('/api/payment/create-intent', {
method: 'POST',
body: JSON.stringify({ amount }),
});
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
catch (error) {
console.error(error);
showToast('Something went wrong. Please try again.');
}
NEXT_PUBLIC_API_URL=https:
STRIPE_SECRET_KEY=sk_test_replace_me
Environment Variable Safety
| Prefix | Bundled in Client? | Safe for Secrets? |
|---|
NEXT_PUBLIC_ | ✅ YES | ❌ NO |
VITE_ | ✅ YES | ❌ NO |
REACT_APP_ (CRA) | ✅ YES | ❌ NO |
| No prefix (Next.js) | ❌ Server only | ✅ YES |
| No prefix (Vite) | ❌ Server only | ✅ YES |
S5 — Input Validation & Sanitization
Client-Side Validation (UX, not security)
Client validation improves UX but is NEVER a security boundary. An attacker bypasses it with DevTools.
ALWAYS Do
import { z } from 'zod';
const ContactFormSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name too long')
.regex(/^[\p{L}\p{M}\s'-]+$/u, 'Invalid characters'),
email: z.string()
.email('Invalid email')
.max(254, 'Email too long'),
phone: z.string()
.regex(/^\+?[\d\s()-]{7,20}$/, 'Invalid phone')
.optional(),
message: z.string()
.min(10, 'Too short')
.max(5000, 'Too long'),
website: z.string()
.url('Invalid URL')
.refine(
(url) => ['http:', 'https:'].includes(new URL(url).protocol),
'Only HTTP/HTTPS URLs allowed'
)
.optional(),
});
type ContactForm = z.infer<typeof ContactFormSchema>;
function handleSubmit(data: unknown): void {
const result = ContactFormSchema.safeParse(data);
if (!result.success) {
return;
}
submitToApi(result.data);
}
<input name="name" maxLength={100} />
<textarea name="message" maxLength={5000} />
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const MAX_FILE_SIZE = 5 * 1024 * 1024;
function validateFile(file: File): boolean {
if (!ALLOWED_TYPES.includes(file.type)) return false;
if (file.size > MAX_FILE_SIZE) return false;
return true;
}
NEVER Do
const emailRegex = /^[a-zA-Z0-9...]{100 chars}$/;
<input type="file" />
const searchUrl = `/search?q=${userInput}`;
const searchUrl = `/search?q=${encodeURIComponent(userInput)}`;
S6 — Content Security Policy (CSP)
Minimum CSP for Modern SPAs
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'nonce-{random}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.yourdomain.com;
frame-src 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
">
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'nonce-{nonce}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' ${process.env.NEXT_PUBLIC_API_URL};
frame-ancestors 'none';
form-action 'self';
`.replace(/\n/g, ''),
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-XSS-Protection', value: '0' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
];
const nextConfig = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};
NEVER Do with CSP
❌ script-src 'unsafe-eval' — allows eval(), defeats XSS protection
❌ script-src 'unsafe-inline' — allows inline scripts, defeats XSS protection
❌ default-src * — allows everything, useless CSP
❌ connect-src * — allows data exfiltration to any domain
S7 — Secure Communication
NEVER Do
fetch('http://api.example.com/data');
fetch(`/api/login?username=${user}&password=${pass}`);
window.addEventListener('message', (event) => {
processData(event.data);
});
ALWAYS Do
fetch('https://api.example.com/data');
window.addEventListener('message', (event) => {
const ALLOWED_ORIGINS = ['https://trusted-domain.com'];
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
processData(event.data);
});
S8 — Third-Party Dependencies
NEVER Do
npm install random-cool-lib
"dependencies": {
"some-lib": "*"
}
<script src="https://cdn.example.com/lib.js"></script>
ALWAYS Do
"dependencies": {
"react": "19.0.0"
}
npm audit
npx audit-ci --moderate
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous"
></script>
npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm
S9 — Sensitive Data in the Browser
NEVER Do
localStorage.setItem('user', JSON.stringify({ ssn: '123-45-6789', ccn: '4111...' }));
console.log('User data:', user);
console.log('Payment info:', paymentDetails);
router.push(`/profile?ssn=${ssn}&dob=${dob}`);
<input type="hidden" name="creditCard" value="4111-1111-1111-1111" />
ALWAYS Do
const maskSSN = (ssn: string): string => `***-**-${ssn.slice(-4)}`;
const maskCard = (card: string): string => `**** **** **** ${card.slice(-4)}`;
const maskEmail = (email: string): string => {
const [user, domain] = email.split('@');
return `${user[0]}***@${domain}`;
};
useEffect(() => {
return () => {
sensitiveDataRef.current = null;
};
}, []);
<input type="password" autoComplete="new-password" />
<input name="cc-number" autoComplete="off" />
export default defineConfig({
esbuild: {
drop: ['console', 'debugger'],
},
});
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.minimizer.forEach((minimizer) => {
if (minimizer.constructor.name === 'TerserPlugin') {
minimizer.options.terserOptions.compress.drop_console = true;
}
});
}
return config;
},
S10 — Open Redirects & URL Manipulation
NEVER Do
const redirectUrl = searchParams.get('redirect');
window.location.href = redirectUrl!;
<a href={userProvidedUrl}>Click here</a>
ALWAYS Do
function safeRedirect(url: string, fallback = '/'): string {
if (url.startsWith('/') && !url.startsWith('//')) {
return url;
}
try {
const parsed = new URL(url);
const ALLOWED_HOSTS = ['app.example.com', 'example.com'];
if (ALLOWED_HOSTS.includes(parsed.hostname)) {
return url;
}
} catch {
}
return fallback;
}
function isSafeHref(href: string): boolean {
if (href.startsWith('/') && !href.startsWith('//')) return true;
try {
const url = new URL(href);
return ['http:', 'https:', 'mailto:'].includes(url.protocol);
} catch {
return false;
}
}
<a href={isSafeHref(url) ? url : '#'}>Link</a>
<a href={externalUrl} target="_blank" rel="noopener noreferrer">
External Link
</a>
Quick Reference — Security Checklist
| Category | Check | Severity |
|---|
| XSS | No innerHTML, dangerouslySetInnerHTML without DOMPurify | CRITICAL |
| XSS | No eval(), new Function(), document.write() | CRITICAL |
| Auth | Tokens in HttpOnly cookies, NOT localStorage | CRITICAL |
| Auth | Proper logout (clear memory + invalidate server session) | HIGH |
| CSRF | SameSite cookies + CSRF token on state-changing requests | HIGH |
| Secrets | No API keys, passwords, or secrets in frontend code | CRITICAL |
| Secrets | No NEXT_PUBLIC_ / VITE_ / REACT_APP_ for secrets | CRITICAL |
| Secrets | No console.log of tokens or user data | HIGH |
| Input | Zod/Yup validation + HTML maxLength + backend validation | HIGH |
| Input | File uploads restricted (type + size) | MEDIUM |
| CSP | Content-Security-Policy header configured | HIGH |
| CSP | No unsafe-eval or unsafe-inline in script-src | HIGH |
| Headers | X-Content-Type-Options, X-Frame-Options, HSTS set | MEDIUM |
| Comms | HTTPS everywhere, no HTTP in production | CRITICAL |
| Comms | postMessage validates origin | HIGH |
| Deps | Lock files committed, npm audit clean | HIGH |
| Deps | External scripts use SRI | MEDIUM |
| Data | PII masked in UI, not in localStorage | HIGH |
| Data | console.log stripped in production builds | MEDIUM |
| URLs | Redirects validated (relative or allowlisted domains) | HIGH |
| URLs | No javascript: protocol in href | CRITICAL |