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 CAPTCHA

Data 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 data

Dependencies

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.