security-patterns
Frontend Security Patterns
XSS Prevention
React's Built-In Protection
React escapes all values rendered in JSX by default. This is safe:
<p>{userInput}</p> // escaped — safe
<div title={userInput}>...</div> // escaped — safe
Dangerous Patterns
// DANGEROUS — renders raw HTML
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// DANGEROUS — inline event handlers from user data
<a href={userProvidedUrl}>Link</a> // javascript: URLs are dangerous
// DANGEROUS — dynamic script injection
document.innerHTML = userInput;
element.insertAdjacentHTML("beforeend", userInput);
Mitigations
Sanitize HTML when you must render user-provided markup:
import DOMPurify from "dompurify";
const cleanHtml = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href", "title"],
});
<div dangerouslySetInnerHTML={{ __html: cleanHtml }} />
Validate URLs before rendering:
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url);
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
<a href={isSafeUrl(url) ? url : "#"}>Link</a>
Content Security Policy (CSP)
HTTP Header
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Meta Tag (Fallback)
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
/>
Key Directives
| Directive | Purpose |
|---|---|
default-src |
Fallback for all resource types |
script-src |
Allowed script sources |
style-src |
Allowed style sources |
connect-src |
Allowed fetch/XHR/WebSocket targets |
img-src |
Allowed image sources |
frame-ancestors |
Who can embed this page ('none' = no iframes) |
base-uri |
Restricts <base> tag URLs |
form-action |
Restricts form submission targets |
Start strict (default-src 'self') and whitelist only what's needed. Use report-uri or report-to to monitor violations in production before enforcing.
CSRF Protection
Token-Based
Include a CSRF token in 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(data),
});
SameSite Cookies
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly; Path=/
SameSite=Lax— cookie sent on top-level navigations but not cross-site subrequests (default in modern browsers).SameSite=Strict— cookie never sent cross-site (may break OAuth flows).- Always combine with
Secure(HTTPS only) andHttpOnly(no JS access).
Authentication Token Handling
Storage Options
| Storage | XSS Risk | CSRF Risk | Use When |
|---|---|---|---|
HttpOnly cookie |
None (not accessible to JS) | Mitigated with SameSite | Server can set cookies |
localStorage |
High (accessible to any script) | None | Never for auth tokens |
| Memory (variable) | Low (cleared on page close) | None | Short-lived SPA sessions |
Prefer HttpOnly cookies for auth tokens — they're invisible to JavaScript and automatically sent with requests.
Token Refresh
let isRefreshing = false;
let pendingRequests: Array<() => void> = [];
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const response = await fetch(url, { ...options, credentials: "include" });
if (response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
isRefreshing = false;
pendingRequests.forEach((cb) => cb());
pendingRequests = [];
}
return new Promise<Response>((resolve) => {
pendingRequests.push(() => {
resolve(fetch(url, { ...options, credentials: "include" }));
});
});
}
return response;
}
Logout
Clear tokens and invalidate the session on the server:
async function logout() {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
});
window.location.href = "/login";
}
Input Validation
Always validate on both client and server — client validation is for UX, server validation is for security.
import { z } from "zod";
const UserInputSchema = z.object({
name: z.string().min(1).max(100).trim(),
email: z.string().email().max(254),
bio: z.string().max(500).optional(),
website: z
.string()
.url()
.optional()
.refine((url) => !url || /^https?:/.test(url), "Must be an HTTP(S) URL"),
});
Never trust client-side validation alone — always validate and sanitize on the server.
Secure Headers
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
| Header | Purpose |
|---|---|
Strict-Transport-Security |
Force HTTPS |
X-Content-Type-Options: nosniff |
Prevent MIME type sniffing |
X-Frame-Options: DENY |
Prevent clickjacking via iframes |
Referrer-Policy |
Control referer header leakage |
Permissions-Policy |
Disable unused browser APIs |
Dependency Security
Audit Regularly
npm audit
npm audit --production # only production deps
Automated Scanning
Enable GitHub Dependabot or Snyk for automated vulnerability alerts:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
Lock Files
Always commit lock files (package-lock.json, pnpm-lock.yaml). Use npm ci in CI to install from the lockfile exactly.
Sensitive Data
- Never commit secrets — use environment variables and
.env.local(gitignored). - Never expose server secrets to the client — in Vite, only
VITE_*vars are client-visible. - Never log sensitive data — PII, tokens, passwords should never appear in console.log or error tracking.
- Mask sensitive fields in error reporting (Sentry, LogRocket):
Sentry.init({
beforeSend(event) {
if (event.request?.headers) {
delete event.request.headers["Authorization"];
delete event.request.headers["Cookie"];
}
return event;
},
});
Third-Party Scripts
- Load third-party scripts with
asyncordefer. - Use Subresource Integrity (SRI) for CDN-hosted scripts:
<script src="https://cdn.example.com/lib.js" integrity="sha384-abc123..." crossorigin="anonymous"></script>
- Sandbox third-party content in iframes with restrictive
sandboxattributes. - Audit third-party scripts regularly — they run with full page access.
Checklist
- No
dangerouslySetInnerHTMLwithout DOMPurify - CSP header configured (at minimum
default-src 'self') - Auth tokens in
HttpOnlycookies, notlocalStorage - SameSite cookies for CSRF protection
- Input validated on both client and server
- Security headers set (HSTS, X-Content-Type-Options, X-Frame-Options)
- Dependencies audited regularly
- No secrets in client-visible code or environment variables
- Third-party scripts use SRI