webdev.complete
🔐 Authentication & Security
🌐The Web Beneath
Lesson 60 of 117
30 min

Security Threats Decoded

XSS, CSRF, SQL injection, prototype pollution. CSP and HSTS.

OWASP keeps a Top 10 list of the vulnerabilities that actually get people pwned. Most of them are not exotic. They're the same five or six mistakes, made over and over, in different stacks. This lesson walks the classics, with a before/after snippet for each so you can spot the bad pattern at code review time.

XSS: cross-site scripting

An attacker injects JavaScript into your page that runs in another user's browser, often via a comment field or URL parameter. From there they can steal cookies, do actions on behalf of the user, or rewrite the page. The classic mistake is treating user-supplied text as HTML.

bad - innerHTML with untrusted input
// User submits: <img src=x onerror="fetch('/api/me').then(...)">
commentEl.innerHTML = userComment;
good - textContent treats it as text
commentEl.textContent = userComment;
// Or, if you really need HTML, sanitize with DOMPurify:
commentEl.innerHTML = DOMPurify.sanitize(userComment);
React, Vue, and Svelte escape by default when you write {userComment}. The footgun is the manual escape hatch: dangerouslySetInnerHTML in React, v-html in Vue. Touch them only with sanitized input.

CSRF: cross-site request forgery

Your user is logged into your bank. They visit evil.com. evil.comhas a hidden form that auto-submits a POST to your bank. The browser, helpful as ever, attaches the user's cookies. Money flies away.

bad - cookie sent on any request, including cross-site
Set-Cookie: session=abc; HttpOnly; Secure
good - SameSite restricts cross-site sending
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax

SameSite=Lax is the modern default and blocks most CSRF. For very sensitive state-changing operations (transfers, password changes), add a CSRF token: a random string put in a meta tag or hidden form field, and required on every POST. The attacker's site can't read your DOM, so it can't forge the token.

SQL injection

You concatenate user input into a SQL string. The user inputs a string that escapes your quotes. They are now writing SQL on your behalf.

bad - string concatenation
// userId = "1; DROP TABLE users; --"
const result = db.query("SELECT * FROM users WHERE id = " + userId);
good - parameterized query
const result = db.query("SELECT * FROM users WHERE id = $1", [userId]);
// or with template-literal tagged drivers:
const result = await sql`SELECT * FROM users WHERE id = ${userId}`;

The parameterized version sends the SQL and the values separately. The value can never break out of its quotes because it isn't in the SQL string at all. ORMs like Prisma and Drizzle do this for you automatically. Just never build SQL with string concatenation.

Prototype pollution

A JavaScript-specific one. If you merge untrusted JSON into an object using a naive recursive function, the attacker can set keys like __proto__.isAdmin, which pollutes the prototype of every object in your program.

bad
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object") {
      merge(target[key] ??= {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

// Attacker sends: { "__proto__": { "isAdmin": true } }
merge(config, JSON.parse(req.body));
// Now ({}).isAdmin === true for every object in the process.
good - reject dangerous keys
function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
    if (typeof source[key] === "object" && source[key] !== null) {
      merge(target[key] ??= Object.create(null), source[key]);
    } else {
      target[key] = source[key];
    }
  }
}
Use Object.create(null) for objects you treat as dictionaries. They have no prototype chain. Or use a real Map. Or use a vetted library like lodash.merge (newer versions are patched).

Clickjacking

Attacker loads your site in an invisible iframe on top of a fake page ("Click here to win!"). User clicks "win," actually clicks "transfer funds" on your site. Defense: tell browsers not to allow your pages in frames.

bash
# Old: X-Frame-Options
X-Frame-Options: DENY

# New: CSP frame-ancestors (preferred)
Content-Security-Policy: frame-ancestors 'self';

CSP and HSTS: two headers worth setting on day one

Content-Security-Policy (CSP)tells the browser what sources of script, style, image, and frame are allowed on your page. It's the second line of defense against XSS: even if an attacker injects a <script>tag, the browser refuses to load it if it's not on the allowlist.

bash
Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';

Start in Content-Security-Policy-Report-Only mode and watch the violation reports. Tighten until clean. CSP done badly is easy to bypass. CSP done well kills most XSS dead.

HTTP Strict-Transport-Security (HSTS)tells the browser "always use HTTPS for this site, even if the user types http://." Prevents the SSL-strip attack where someone on a coffee-shop network downgrades you to plain HTTP.

bash
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Once you set HSTS with a long max-age, you cannot easily back out. The browser will refuse plain HTTP for a year. Make sure HTTPS works everywhere before you ship this.

The 80/20 checklist

  • Set HttpOnly; Secure; SameSite=Lax on session cookies.
  • Use parameterized queries. Never concatenate SQL.
  • Use your framework's default escaping. Treat manual innerHTML / dangerouslySetInnerHTML as radioactive.
  • Add CSP and HSTS headers. Start CSP in report-only.
  • Validate input on the server, even if you also validate on the client.
  • Hash passwords with argon2id or bcrypt (next lesson).
  • Keep dependencies updated. npm audit isn't magic but it catches the obvious.

Quiz

Quiz1 / 4

What&apos;s the simplest cookie attribute that blocks most CSRF?

Recap

  • XSS: don't insert untrusted strings as HTML. Use framework escaping or DOMPurify.
  • CSRF: SameSite=Lax + a token on sensitive POSTs.
  • SQL injection: parameterized queries, always.
  • Prototype pollution: reject __proto__/constructor keys, prefer Map or Object.create(null).
  • Clickjacking: frame-ancestors in CSP.
  • CSP + HSTS: defense in depth. Set them.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.