Security Best Practices
Every line of code is an attack surface. Learn to write defensively.
XSS
Cross-Site Scripting — the #1 web vulnerability.
// XSS: Attacker injects malicious scripts into your page.
// Types: Stored, Reflected, DOM-based.
// BAD — directly inserting user input:
const name = getUserInput(); // "<script>steal(cookies)</script>"
element.innerHTML = name; // EXECUTES the script! 💀
// BAD — template literal in innerHTML:
element.innerHTML = `<h1>Hello, ${userInput}</h1>`; // XSS!
// GOOD — use textContent (auto-escapes):
element.textContent = name; // Displays as text, not HTML
// GOOD — sanitize if HTML is needed:
import DOMPurify from "dompurify";
element.innerHTML = DOMPurify.sanitize(userHtml);XSS happens when user input is rendered as code. The fix: never trust user input, always escape or sanitize.
1 / 2
Input Validation
Validate on the server. Sanitize on the client. Trust nothing.
// RULE: Client validation = UX. Server validation = security.
// Attackers bypass client-side checks trivially.
// Server-side validation (with Zod):
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(72),
age: z.number().int().min(13).max(150),
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s]+$/),
});
function createUser(input) {
const result = UserSchema.safeParse(input);
if (!result.success) {
return { error: result.error.flatten() };
}
// result.data is typed AND validated
return saveToDb(result.data);
}
// SQL Injection prevention:
// BAD — string concatenation:
const query = `SELECT * FROM users WHERE id = ${userId}`;
// userId = "1; DROP TABLE users;" 💀
// GOOD — parameterized queries:
const result = await db.query(
"SELECT * FROM users WHERE id = $1",
[userId] // safely escaped by the driver
);
// Path traversal prevention:
// BAD:
const file = req.params.filename; // "../../etc/passwd"
fs.readFile(`./uploads/${file}`); // reads system files!
// GOOD:
const safeName = path.basename(req.params.filename);
const filePath = path.join("./uploads", safeName);Authentication
Prove identity securely. Protect sessions.
// Password storage — NEVER store plaintext:
import bcrypt from "bcrypt";
// Hash on signup:
const hash = await bcrypt.hash(password, 12); // 12 rounds
await db.insert({ email, passwordHash: hash });
// Verify on login:
const user = await db.findByEmail(email);
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new Error("Invalid credentials");
// JWT best practices:
// - Short expiration (15min for access tokens)
// - Store in httpOnly cookies (not localStorage!)
// - Include only necessary claims (no passwords)
// - Use refresh tokens for longer sessions
// CSRF protection:
// Attacker tricks user's browser into making requests:
// <img src="https://bank.com/transfer?to=attacker&amount=1000">
// Defenses:
// 1. SameSite cookies (Lax or Strict):
// Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly
//
// 2. CSRF tokens — unique per session:
// Server sends token → client includes in requests
// Server verifies token matches session
//
// 3. Check Origin/Referer headers
// Rate limiting — prevent brute force:
// Allow 5 login attempts per 15 minutes per IP
// After that: exponential backoff or CAPTCHAData Exposure
Don't leak secrets. Encrypt sensitive data.
// Environment variables — keep secrets out of code:
// .env (NEVER commit this file):
// DATABASE_URL=postgres://user:pass@host/db
// API_SECRET=sk_live_abc123
// Access in code:
const secret = process.env.API_SECRET;
// .gitignore:
// .env
// .env.local
// .env*.local
// HTTPS everywhere — encrypt data in transit:
// Modern hosting (Vercel, Cloudflare) handles this
// For custom servers: use Let's Encrypt certificates
// Don't expose sensitive data in responses:
// BAD:
app.get("/api/user/:id", async (req, res) => {
const user = await db.findById(req.params.id);
res.json(user); // includes passwordHash, tokens, etc!
});
// GOOD — pick only safe fields:
app.get("/api/user/:id", async (req, res) => {
const user = await db.findById(req.params.id);
const { id, name, email, avatar } = user;
res.json({ id, name, email, avatar });
});
// Client-side storage rules:
// localStorage — NEVER for tokens/secrets (XSS accessible)
// sessionStorage — same risk as localStorage
// httpOnly cookies — safe from XSS (server-only access)
// IndexedDB — encrypted if storing sensitive local dataDependencies
Your code is only as secure as your weakest dependency.
// Audit dependencies regularly:
npm audit // check for known vulnerabilities
npm audit fix // auto-fix where possible
pnpm audit // same for pnpm
// Lock file — pin exact versions:
// package-lock.json or pnpm-lock.yaml
// ALWAYS commit lock files!
// Without them: npm install may get different (vulnerable) versions
// Evaluate before installing:
// Ask: Do I really need this package?
// Check: downloads, maintenance, open issues, last update
// Tools: npms.io, snyk.io/advisor, bundlephobia.com
// Supply chain attacks are real:
// - Typosquatting: "lodsh" instead of "lodash"
// - Maintainer account compromise
// - Malicious postinstall scripts
// Defenses:
// 1. Use exact versions: "lodash": "4.17.21" (not "^4.17.21")
// 2. Review lock file diffs in PRs
// 3. Use npm/pnpm overrides for transitive dependency fixes:
{
"pnpm": {
"overrides": {
"vulnerable-pkg": ">=2.0.1"
}
}
}
// Automated tools:
// - GitHub Dependabot (auto-PRs for updates)
// - Snyk (continuous monitoring)
// - Socket.dev (detect supply chain risks)FAQ
Common questions about security.