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.
// User submits: <img src=x onerror="fetch('/api/me').then(...)">
commentEl.innerHTML = userComment;commentEl.textContent = userComment;
// Or, if you really need HTML, sanitize with DOMPurify:
commentEl.innerHTML = DOMPurify.sanitize(userComment);{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.
Set-Cookie: session=abc; HttpOnly; SecureSet-Cookie: session=abc; HttpOnly; Secure; SameSite=LaxSameSite=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.
// userId = "1; DROP TABLE users; --"
const result = db.query("SELECT * FROM users WHERE id = " + userId);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.
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.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];
}
}
}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.
# 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.
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.
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.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.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types defaultThis 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.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadmax-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.
- A01 Broken Access Control
- A02 Security Misconfiguration
- A03 Software Supply Chain Failures
- A04 Cryptographic Failures
- A05 Injection
- A06 Insecure Design
- A07 Authentication Failures
- A08 Software or Data Integrity Failures
- A09 Security Logging and Alerting Failures
- 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=Laxon session cookies. - Use parameterized queries. Never concatenate SQL.
- Use your framework's default escaping. Treat manual
innerHTML/dangerouslySetInnerHTMLas 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 auditisn't magic but it catches the obvious.
Quiz
What'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__/constructorkeys, preferMaporObject.create(null). - Clickjacking:
frame-ancestorsin CSP. - CSP + HSTS: defense in depth. Set them.