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.com has 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.

Host allowlists are the weak kind of CSP
The policy above is an allowlist, and allowlists leak. If cdn.example.com hosts one old copy of AngularJS or a JSONP endpoint, an attacker can use it to run script that your policy happily permits. Google's recommendation is a strict CSP built on a per-response nonce plus 'strict-dynamic', which drops host allowlists entirely.
bash
Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

Generate {RANDOM} fresh for every response, at least 128 bits, base64-encoded, and stamp it on each <script nonce="..."> you actually intended. 'strict-dynamic' then lets those trusted scripts load their own dependencies without you enumerating hosts. For a static site served from a cache, where you cannot vary the header per response, use script hashes instead of a nonce.

Trusted Types

CSP stops script the browser fetches. It does nothing about your own code assigning a string to innerHTML. Trusted Types closes that hole: with the header below, passing a plain string to a DOM sink throws, and you must route it through a policy that sanitizes first.

bash
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default

This was a Chromium-only feature for years, which is why older articles treat it as unshippable. It reached Baseline in February 2026 and now works across current browsers.

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 current Top 10

The list itself is worth reading once so you know the vocabulary that shows up in audits and pentest reports. OWASP publishes a new edition every few years; the 2025 edition replaced the 2021 one that most blog posts still quote.

  1. A01 Broken Access Control
  2. A02 Security Misconfiguration
  3. A03 Software Supply Chain Failures
  4. A04 Cryptographic Failures
  5. A05 Injection
  6. A06 Insecure Design
  7. A07 Authentication Failures
  8. A08 Software or Data Integrity Failures
  9. A09 Security Logging and Alerting Failures
  10. A10 Mishandling of Exceptional Conditions

Three changes from the 2021 list are worth knowing, because they tell you where the industry got hurt. Software Supply Chain Failures is new and jumped straight to third: it grew out of the old "vulnerable and outdated components" entry, and it covers your build pipeline and your transitive dependencies, not just the version numbers in your package.json. Mishandling of Exceptional Conditions is also new, and it is the one this lesson's error-handling advice maps to: code that fails open, swallows errors, or leaks internals in a stack trace. And SSRF is no longer its own entry; it was folded into Broken Access Control.

Security Misconfiguration also climbed from fifth to second, which matches what the rest of this lesson is about. Most breaches are not clever.

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, and prefer a nonce plus 'strict-dynamic' over a host allowlist.
  • 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.