Security Patterns
Security Patterns
Security isn't an afterthought—it's fundamental. Understanding common vulnerabilities and how to prevent them is essential for every frontend developer.
Cross-Site Scripting (XSS)
Attackers inject malicious scripts that execute in users' browsers.
Types of XSS
Stored XSS: Malicious script stored in database
├── User posts comment: <script>stealCookies()</script>
├── Saved to database
└── Executed when others view the comment
Reflected XSS: Script in URL executed immediately
├── Attacker crafts URL: site.com/search?q=<script>evil()</script>
├── Victim clicks link
└── Script executes in victim's browser
DOM-based XSS: Client-side code processes unsafe data
├── page.html#<script>evil()</script>
├── JS reads location.hash and inserts into DOM
└── Script executesPrevention
// ❌ DANGEROUS: Direct HTML insertion
element.innerHTML = userInput;
document.write(userInput);
// ✅ SAFE: Text content (automatically escaped)
element.textContent = userInput;
// ✅ SAFE: Using DOM APIs
const div = document.createElement("div");
div.textContent = userInput;
parent.appendChild(div);
// Sanitizing HTML when you must render it
import DOMPurify from "dompurify";
function SafeHTML({ html }) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href", "target"],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
// React automatically escapes by default
function SafeComponent({ userInput }) {
return <div>{userInput}</div>; // Escaped automatically
}
// ❌ DANGEROUS: React escape hatch
function DangerousComponent({ userInput }) {
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
}URL Handling
// ❌ DANGEROUS: Unvalidated URLs
<a href={userProvidedUrl}>Link</a>;
// This allows: javascript:alert('XSS')
// ✅ SAFE: Validate URL protocol
function isSafeUrl(url) {
try {
const parsed = new URL(url);
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
function SafeLink({ url, children }) {
const href = isSafeUrl(url) ? url : "#";
return <a href={href}>{children}</a>;
}Content Security Policy (CSP)
Tells browsers what resources are allowed to load.
<!-- HTTP header or meta tag -->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
"
/>
<!-- Inline scripts need nonce -->
<script nonce="abc123">
// Allowed because nonce matches
</script>// Next.js CSP configuration
// next.config.js
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: cspHeader.replace(/\n/g, ""),
},
],
},
];
},
};Cross-Site Request Forgery (CSRF)
Attackers trick users into performing unwanted actions on sites where they're authenticated.
How CSRF Works
1. User logs into bank.com (session cookie set)
2. User visits evil.com
3. evil.com has: <img src="bank.com/transfer?to=attacker&amount=1000">
4. Browser sends request WITH user's session cookie
5. Transfer executes as authenticated userPrevention
// 1. CSRF Tokens
// Server generates unique token per session/request
// Server-side (example)
app.get("/form", (req, res) => {
const csrfToken = generateToken();
req.session.csrfToken = csrfToken;
res.render("form", { csrfToken });
});
// Client-side
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value={csrfToken} />
<input type="text" name="amount" />
<button type="submit">Transfer</button>
</form>;
// Or with fetch
async function submitForm(data) {
const response = await fetch("/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken(),
},
body: JSON.stringify(data),
credentials: "include",
});
}
// 2. SameSite Cookies (modern approach)
// Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
// 3. Check Origin/Referer headers (server-side)
app.use((req, res, next) => {
const origin = req.headers.origin;
const referer = req.headers.referer;
if (origin && !allowedOrigins.includes(origin)) {
return res.status(403).json({ error: "Invalid origin" });
}
next();
});Injection Attacks
Injection attacks occur when untrusted data is sent to an interpreter as part of a command or query. While SQL injection primarily happens on the backend, frontend developers must understand it to build secure applications end-to-end.
Why frontend developers should care:
- You control what data gets sent to the API
- Client-side validation is the first line of defense (but not the only one)
- Understanding attacks helps you design safer interfaces
- Some injection attacks (like template injection) happen client-side
SQL Injection (via API)
The attack: An attacker enters malicious SQL in a form field. If the backend concatenates this directly into a query, the attacker can read, modify, or delete data.
Frontend's role: Validate input format before sending. But remember—never rely solely on frontend validation. Attackers can bypass your UI entirely.
// ❌ NEVER: String concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// Attacker: email = "'; DROP TABLE users; --"
// ✅ ALWAYS: Parameterized queries (backend)
const query = "SELECT * FROM users WHERE email = $1";
db.query(query, [email]);
// Frontend: validate before sending
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}Command Injection Prevention
The attack: If user input is passed to shell commands (like file uploads with user-provided names), attackers can inject shell commands.
Example: A filename like ; rm -rf / could be catastrophic if passed unsanitized to a shell command.
Frontend's role: Sanitize filenames and user-provided strings before sending to the server. Remove or escape special characters.
// Frontend validation
function sanitizeFilename(filename) {
// Remove path traversal and special characters
return filename
.replace(/\.\./g, "")
.replace(/[/\\]/g, "")
.replace(/[^a-zA-Z0-9._-]/g, "");
}Template Injection
The attack: If user input is embedded in template strings or passed to eval(), attackers can execute arbitrary JavaScript in other users' browsers.
Why eval is dangerous:
- Executes ANY string as code
- Has full access to your application's scope
- Attacker can steal data, hijack sessions, modify the DOM
Rule: Never use eval(), new Function(), or innerHTML with user-provided content.
// ❌ DANGEROUS: eval and Function constructor
eval(userInput);
new Function(userInput)();
// ❌ DANGEROUS: Template literal injection
const template = `<div>${userInput}</div>`;
// ✅ SAFE: Use safe templating
function renderTemplate(template, data) {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return escapeHtml(data[key] || "");
});
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}Authentication Security
Authentication is the most security-critical part of most applications. Mistakes here can expose all user data.
Secure Token Storage
The problem: Where do you store authentication tokens on the client? Each option has trade-offs:
| Storage | XSS Vulnerable | CSRF Vulnerable | Persists |
|---|---|---|---|
| localStorage | YES | No | Yes |
| sessionStorage | YES | No | No |
| HttpOnly Cookie | No | YES (mitigatable) | Yes |
| Memory (JS variable) | No | No | No |
The recommended approach:
- Store refresh tokens in HttpOnly cookies (XSS can't read them)
- Store short-lived access tokens in memory (cleared on refresh)
- Implement token refresh to get new access tokens
This way, XSS can't steal long-lived tokens, and CSRF is prevented by not auto-sending access tokens.
// ❌ DANGEROUS: localStorage for auth tokens
localStorage.setItem("authToken", token);
// Vulnerable to XSS - any script can read it
// ✅ BETTER: HttpOnly cookies (set by server)
// Set-Cookie: authToken=xxx; HttpOnly; Secure; SameSite=Strict
// ✅ ACCEPTABLE: Memory storage (for SPAs)
let authToken = null;
function setToken(token) {
authToken = token;
// Token cleared on page refresh
}
// ✅ For refresh tokens
// Store refresh token in HttpOnly cookie
// Store short-lived access token in memory
class TokenManager {
constructor() {
this.accessToken = null;
this.refreshPromise = null;
}
async getToken() {
if (this.accessToken && !this.isExpired(this.accessToken)) {
return this.accessToken;
}
// Refresh token stored in HttpOnly cookie
// Server validates cookie and returns new access token
return this.refresh();
}
async refresh() {
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = fetch("/api/refresh", {
method: "POST",
credentials: "include", // Send HttpOnly cookie
})
.then((res) => res.json())
.then((data) => {
this.accessToken = data.accessToken;
return this.accessToken;
})
.finally(() => {
this.refreshPromise = null;
});
return this.refreshPromise;
}
}Password Handling
Golden rules for passwords:
- Never store passwords client-side (no localStorage, no cookies)
- Never log passwords (not even during development)
- Never send in URLs (URLs are logged in server logs, browser history)
- Always use HTTPS (passwords sent over HTTP can be intercepted)
- Clear from memory after sending (minimize exposure window)
Additional best practices:
- Use
type="password"(hides input, enables password managers) - Add
autoComplete="current-password"(helps password managers) - Consider adding password strength indicators
- Never limit password length or character sets
// NEVER store passwords client-side
// NEVER log passwords
// NEVER send passwords in URLs
// Secure password form
function LoginForm() {
const [password, setPassword] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
// Send over HTTPS only
await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
credentials: "include",
});
// Clear password from memory
setPassword("");
};
return (
<form onSubmit={handleSubmit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<button type="submit">Login</button>
</form>
);
}Third-Party Dependencies
Your application's security is only as strong as its weakest dependency. A vulnerability in a popular npm package can affect millions of sites.
The supply chain problem:
- Average project has hundreds of dependencies (including transitive)
- Each dependency is code you didn't write and may not have audited
- Attackers target popular packages (event-stream incident, ua-parser-js)
- CDN-hosted libraries can be tampered with
Auditing Dependencies
Regular audits are essential. Run these commands regularly (ideally in CI/CD):
# Check for known vulnerabilities
npm audit
yarn audit
pnpm audit
# Fix automatically if possible
npm audit fix
# Check specific package
npx snyk testSubresource Integrity (SRI)
The problem: When loading scripts from CDNs, you're trusting that CDN to serve the exact code you expect. If the CDN is compromised, attackers could inject malicious code into every site using it.
The solution: SRI lets you specify a cryptographic hash of the expected file. The browser will only execute the script if its hash matches.
How it works:
- You calculate a hash of the library file (SHA-256, SHA-384, or SHA-512)
- You add the hash to the
integrityattribute - Browser downloads the file and calculates its hash
- If hashes don't match, browser refuses to execute the script
Generate SRI hashes: Use shasum command or https://www.srihash.org/
<!-- Verify CDN resources haven't been tampered with -->
<script
src="https://cdn.example.com/library.js"
integrity="sha384-abc123..."
crossorigin="anonymous"
></script>
<link
rel="stylesheet"
href="https://cdn.example.com/styles.css"
integrity="sha384-xyz789..."
crossorigin="anonymous"
/>Sandboxing Iframes
<!-- Restrict iframe capabilities -->
<iframe
src="https://third-party.com/widget"
sandbox="allow-scripts allow-same-origin"
allow="camera 'none'; microphone 'none'"
></iframe>
<!-- Sandbox options:
allow-scripts: Allow JavaScript
allow-same-origin: Allow same-origin access
allow-forms: Allow form submission
allow-popups: Allow window.open
allow-modals: Allow alerts/confirms
-->Secure Communication
HTTPS Only
// Redirect HTTP to HTTPS (server-side)
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === "production") {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});
// HTTP Strict Transport Security (HSTS)
// Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadSecure Headers
// Essential security headers (Next.js example)
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-XSS-Protection",
value: "1; mode=block",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
],
},
];
},
};Input Validation
// Comprehensive input validation
const validators = {
email: (value) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return pattern.test(value);
},
username: (value) => {
// Alphanumeric, 3-20 chars
const pattern = /^[a-zA-Z0-9_]{3,20}$/;
return pattern.test(value);
},
url: (value) => {
try {
const url = new URL(value);
return ["http:", "https:"].includes(url.protocol);
} catch {
return false;
}
},
phone: (value) => {
// Basic phone validation
const pattern = /^\+?[\d\s-]{10,}$/;
return pattern.test(value);
},
safeString: (value, maxLength = 1000) => {
if (typeof value !== "string") return false;
if (value.length > maxLength) return false;
// No control characters except newlines
const pattern = /^[\x20-\x7E\n\r\t]*$/;
return pattern.test(value);
},
};
function validateInput(value, type) {
const validator = validators[type];
if (!validator) throw new Error(`Unknown validator: ${type}`);
return validator(value);
}Security Checklist
Authentication & Authorization:
□ Use HttpOnly cookies for session tokens
□ Implement proper logout (invalidate server-side)
□ Use CSRF protection for state-changing requests
□ Validate authorization on every protected request
Data Handling:
□ Never trust client-side data
□ Validate and sanitize all inputs
□ Encode output appropriately (HTML, URL, JS)
□ Use parameterized queries
Transport:
□ Use HTTPS everywhere
□ Set secure cookie flags
□ Implement HSTS
□ Use SRI for third-party resources
Headers:
□ Set Content-Security-Policy
□ Set X-Content-Type-Options
□ Set X-Frame-Options
□ Set Referrer-Policy
Dependencies:
□ Audit regularly (npm audit)
□ Keep dependencies updated
□ Use lockfiles
□ Review new dependencies before addingSummary
| Vulnerability | Prevention |
|---|---|
| XSS | Escape output, CSP, sanitize HTML |
| CSRF | CSRF tokens, SameSite cookies |
| Injection | Validate input, parameterized queries |
| Token theft | HttpOnly cookies, short-lived tokens |
| Clickjacking | X-Frame-Options, CSP frame-ancestors |
| MITM | HTTPS, HSTS, secure cookies |
Key Principles:
- Never trust user input
- Defense in depth (multiple layers)
- Fail securely (deny by default)
- Keep dependencies updated
- Audit and test regularly