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