Security
The mistakes that turn into a real incident, not just a bug.
XSS — three flavors, one root cause
Cross-site scripting is always the same underlying failure: text that came from somewhere untrusted got treated as markup instead of data. The three flavors differ only in where the untrusted text entered.
| Flavor | Untrusted text comes from |
|---|---|
| Stored | the database — a comment, a username, a bio someone else submitted, rendered later for other visitors |
| Reflected | the current request — a URL/search-query parameter echoed straight into the page's HTML |
| DOM-based | client-side JS itself — reading location.hash or similar and writing it into the DOM, no server involved at all |
// the actual vulnerable line looks the same in all three flavors:
el.innerHTML = someValueThatCameFromOutsideThisFile;
// the fix is the same in all three, too:
el.textContent = someValueThatCameFromOutsideThisFile; // never parsed as HTML — see the DOM chapter
// — or, if actual formatted HTML is genuinely needed, sanitize FIRST:
el.innerHTML = DOMPurify.sanitize(someValueThatCameFromOutsideThisFile);
textContent by default.
innerHTML only for markup you trust completely — your
own hardcoded strings, or output that's been through a real
sanitizer. "I'll just strip <script> tags myself"
is not a sanitizer; there are too many other ways to smuggle
executable content into HTML (an onerror attribute on an
<img>, a javascript: URL) to
reimplement correctly by hand.
A Content-Security-Policy header is the defense-in-depth layer underneath sanitization — even if a payload does slip through, a strict CSP can refuse to execute it:
Content-Security-Policy: script-src 'self'; object-src 'none'
That policy tells the browser to run scripts only from the site's own
origin — an injected <script src="https://evil.example">
or an inline <script>alert(1)</script> both
get refused at the browser level, entirely independent of whether the
injection itself was ever caught.
CSRF, SameSite, and CORS — three names for "who's actually making this request"
A browser attaches cookies to a request automatically, based purely
on the target domain — it doesn't check which site's page
triggered the request. CSRF abuses exactly that: a malicious
page auto-submits a form to
your-bank.com/transfer, and the browser happily attaches
the visitor's real, valid your-bank.com session cookie
to it, because from the cookie's point of view it's a normal request
to the right domain.
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
SameSite value |
Cookie sent on a cross-site request? |
|---|---|
Strict | never |
Lax (most browsers' default today) | only on top-level navigation (clicking a real link), not on a background fetch/form auto-submit |
None | always — requires Secure too |
CORS, revisited: it's the
opposite direction from CSRF — CORS decides whether JavaScript
can read the response of a cross-origin request; it does nothing
to stop the request from being sent in the first place (a
plain HTML form auto-submit isn't subject to CORS at all).
SameSite cookies are what actually close the CSRF hole;
CORS closes a different one.
Prototype pollution
A "deep merge" utility that copies keys with a plain
for...in and bracket assignment has a landmine baked in:
"__proto__" is a legal object key, and writing through
it doesn't set a normal property — it reaches all the way up to
Object.prototype itself.
function unsafeMerge(target, source) {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeMerge(target[key], source[key]); // recurses into "__proto__" just like any other key
} else {
target[key] = source[key];
}
}
return target;
}
const attackerPayload = JSON.parse('{"__proto__": {"isAdmin": true}}');
unsafeMerge({}, attackerPayload);
({}).isAdmin; // true — on a BRAND NEW, completely unrelated object, anywhere in the whole program
"__proto__", "constructor", and
"prototype" explicitly during a merge, or build the
result with Object.create(null) so it has no prototype
at all to pollute.
Supply-chain risk
Every dependency's postinstall script and every
transitive dependency (a dependency of a dependency, several layers
deep, that nobody on the team ever chose or reviewed) runs with the
same trust and access as your own code the moment
npm install finishes. A compromised popular package —
through a hijacked maintainer account or a typosquatted name one
character off from a real one — is a genuine, repeatedly-realized
attack vector, not a hypothetical one.
A committed lockfile (already covered) is part of the defense here too: it pins the exact resolved tree, so a compromised new version of a transitive dependency published after the lockfile was generated doesn't get silently pulled in on the next install.
innerHTML and postMessage, safely
// postMessage — always check the origin, on both ends
window.addEventListener("message", (event) => {
if (event.origin !== "https://trusted-partner.example") return; // reject everything else
handleMessage(event.data);
});
otherWindow.postMessage(payload, "https://trusted-partner.example"); // NEVER "*" for anything sensitive
event.origin check, any page anywhere that
can get a reference to your window (an iframe embedding it, a popup
it opened) can send it a message your handler will act on as if it
were trusted. Sending with "*" as the target origin has
the same problem in the other direction — the payload gets delivered
to whatever page is currently there, trusted or not.
Auth in practice: where does the token actually live?
Two real options, and each one is exactly vulnerable to the attack the other one already closed:
localStorage |
httpOnly cookie |
|
|---|---|---|
| Readable by JavaScript | yes — including any injected XSS payload | no — invisible to JS entirely, by design |
| Sent automatically on every matching request | no — you attach it yourself | yes — which is what makes CSRF possible against it |
| Vulnerable to | XSS (any injected script can just read and exfiltrate it) | CSRF (unless paired with the SameSite cookie flag covered earlier in this chapter) |
httpOnly cookie with
SameSite=Lax or Strict closes both holes at
once — invisible to a successful XSS payload, and not sent on the
cross-site requests CSRF depends on. localStorage is popular because
it's simple to reach from JS, not because it's the safer choice.
JWT — signed, not encrypted
A JSON Web Token is three base64url segments joined by dots:
header.payload.signature. The signature proves the
payload wasn't tampered with — it proves nothing about who
can read it, because the header and payload are just encoded,
never encrypted.
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkFuYSJ9" +
".signature-goes-here";
const [headerPart, payloadPart] = token.split(".");
const decode = (part) => JSON.parse(atob(part));
console.log(decode(headerPart)); // what happens?
console.log(decode(payloadPart)); // what happens — with zero knowledge of the signing secret?
{ alg: "HS256", typ: "JWT" }, then
{ sub: "user123", name: "Ana" } — fully readable, no
secret required, just atob. Anyone holding a JWT can
read every claim inside it. Never put a password, a secret, or
anything genuinely sensitive in the payload — the signature stops
someone from forging or editing a valid-looking token, not
from reading one they already have.
Session vs token auth
| Session (stateful) | Token / JWT (stateless) | |
|---|---|---|
| Server keeps | a session store (Redis, DB) mapping an id to who's logged in | nothing — the token itself carries the claims |
| Checking a request | look the session id up in the store | verify the signature — no lookup, no shared store needed |
| Revoking access instantly | delete the session server-side, done | not until it naturally expires — see logout, below |
| Scales across servers | needs a shared session store | trivially — any server with the public key/secret can verify it alone |
Refresh tokens
The practical compromise: a short-lived access token (minutes)
sent with every request, and a long-lived refresh token
(days/weeks), stored more carefully and used only to silently obtain
a new access token when the old one expires. A stolen access token
is only dangerous for minutes; a stolen refresh token is the actually
serious leak, which is exactly why it's the one worth putting behind
httpOnly and tighter handling.
What logout actually does
exp claim passes. Real logout-everywhere needs either
short expiries, or a server-side blocklist of revoked token ids —
the exact statefulness JWTs were chosen to avoid, reintroduced for
the one operation that genuinely needs it.
A session-based setup doesn't have this problem at all — logout is just deleting the session server-side, immediately effective everywhere that session's id was in use. It's the one place session auth is strictly simpler than tokens, and why some real systems use a short-lived JWT for the access token but fall back to a genuine server-side session (or a stored, revocable refresh token) for anything that needs a hard, immediate logout.
Opens in the editor — write it, run it, and check it against real tests.