Security patterns for client-side applications.
Running entirely in the browser means:
- No server-side vulnerabilities (SQL injection, SSRF, etc.)
- No user data transmitted over the network
- No authentication tokens to protect
But client-side apps still face:
- XSS (Cross-Site Scripting)
- Unsafe dynamic code execution
- Clipboard/file injection
- Third-party script risks
Configure CSP headers in your hosting platform. This is your primary defense against XSS.
// Example: vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'"
}
]
}
]
}Key directives:
script-src 'self'-- Only run scripts from your domainobject-src 'none'-- Block Flash/Java pluginsframe-ancestors 'none'-- Prevent clickjacking (embedding in iframes)
When to relax CSP: If you load fonts from Google Fonts, scripts from a CDN, or images from external sources, add those specific domains. Never use unsafe-eval or wildcard *.
Any time you render user-provided content as HTML, sanitize it:
import DOMPurify from 'dompurify';
// Safe
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
// UNSAFE - never do this
<div dangerouslySetInnerHTML={{ __html: userInput }} />When this matters: Markdown previews, rich text editors, HTML entity tools, any feature that renders user input as markup.
If a feature evaluates user expressions (calculator, formula parser):
// UNSAFE
const result = eval(userInput);
// SAFER -- token allowlist
const ALLOWED_TOKENS = /^[0-9+\-*/().%\s]+$/;
if (!ALLOWED_TOKENS.test(userInput)) {
throw new Error('Invalid expression');
}
const result = new Function(`return (${userInput})`)();Even with new Function(), validate the input against an allowlist of expected tokens first. Never pass raw user input to any code execution function.
For features that involve hashing, encryption, or secure random generation:
DO:
// Use Web Crypto API
const hash = await crypto.subtle.digest('SHA-256', data);
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
// Use crypto.getRandomValues for secure randomness
const array = new Uint8Array(32);
crypto.getRandomValues(array);DON'T:
// Never use Math.random() for security
const password = Math.random().toString(36); // PREDICTABLE
// Never implement custom crypto
const encrypted = text.split('').reverse().join(''); // NOT ENCRYPTIONWhen features accept file uploads:
// Validate MIME type
const allowedTypes = /^image\/(png|jpeg|gif|webp)$/;
if (!allowedTypes.test(file.type)) {
throw new Error('Invalid file type');
}
// Validate file size
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_SIZE) {
throw new Error('File too large');
}The useFileUpload hook handles both validations. See Hooks Reference.
Beyond CSP, configure these headers on your hosting platform:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevent MIME type sniffing |
X-Frame-Options |
DENY |
Prevent clickjacking |
Referrer-Policy |
strict-origin-when-cross-origin |
Limit referrer information |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disable unused browser APIs |
Note: Only restrict APIs you don't use. If your app needs camera access (e.g., QR scanner), don't block it in Permissions-Policy.
- Passwords or secrets in localStorage (readable by any script on the same origin)
- API keys in client-side code (visible in source/network tab)
- Encryption keys in localStorage (derive from user password instead)
If a feature encrypts user data (e.g., a secure notes tool), derive the key from a user-provided password using PBKDF2 and never persist the key itself.