A7 · Part 3 · Advanced
Security
The mistakes that turn into a real incident, not just a bug.
16On this page
- XSS — three flavors, one root cause
- CSRF, SameSite, and CORS — three names for "who's actually making this request"
- The CORS preflight — the request browsers send before yours
- Clickjacking — hijacking a click the user thinks landed elsewhere
- Prototype pollution
- Supply-chain risk
- crypto.subtle — hashing and randomness, done correctly
- innerHTML and postMessage, safely
- Subresource Integrity — trusting a third-party script
- Trusted Types — closing the innerHTML hole at the platform level
- Auth in practice: where does the token actually live?
- JWT — signed, not encrypted
- Session vs token auth
- Refresh tokens
- OAuth and PKCE — delegating login without ever seeing a password
- What logout actually does
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);
Rule
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.
A host allowlist like that is also its weakness: every domain you allow, and
every endpoint on it, can now serve script. Modern guidance is a
nonce-based policy with 'strict-dynamic':
Content-Security-Policy: script-src 'nonce-8fJ2kQ9x...' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="8fJ2kQ9x..." src="/app.js"></script> // runs: carries the nonce
<script>stolen()</script> // blocked: no nonce
The server generates a fresh, unguessable nonce for every response and
stamps it on its own script tags; an attacker who injects markup cannot know
it. 'strict-dynamic' then trusts scripts that a script that carries a nonce
loads, so you no longer maintain a list of CDNs, and browsers that support it
ignore 'self' and host allowlists. Because the nonce changes on
every request, a fully cached static page cannot carry one; use hash-based
sources for those.
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 (what Chrome and Edge assume when the attribute is missing — Firefox and Safari don't, so set it explicitly) | only on top-level GET navigation (clicking a link), not on a background fetch or a cross-site POST form |
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.
The CORS preflight — the request browsers send before yours
// your JS calls:
fetch("https://api.example.com/users", {
method: "DELETE",
headers: { "X-Custom-Header": "yes" },
});
// the browser actually sends THIS first, automatically, before your request:
OPTIONS /users HTTP/1.1
Origin: https://your-app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: X-Custom-Header
A "simple" request (a plain GET, or a POST
with only the handful of headers a plain HTML form could already
send) skips this step. Anything else — a custom header, JSON with
Content-Type: application/json, a
PUT/DELETE — triggers a
preflight: the browser asks first, via OPTIONS,
whether the actual request is even allowed, and only sends the real
one if the server's preflight response says yes.
// the server's preflight RESPONSE has to explicitly allow all of it:
Access-Control-Allow-Origin: https://your-app.com
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: X-Custom-Header
⚠ A missing preflight response looks like a network failure
If the server doesn't answer the
OPTIONS request
correctly — a common miss when someone hand-rolls CORS headers only
on the "real" route — the browser never even attempts the actual
request. The error shows up as a vague CORS failure on the original
call, and the fix is almost always making sure the server responds to
OPTIONS too, not just DELETE.
Clickjacking — hijacking a click the user thinks landed elsewhere
Content-Security-Policy: frame-ancestors 'self'
// or the older, single-purpose header:
X-Frame-Options: DENY
Clickjacking loads a real, legitimate page inside an invisible
<iframe>, layered under a decoy the attacker
controls — a "claim your prize" button sitting exactly on top of the
real page's "transfer funds" button. The victim thinks they clicked
the decoy; the click actually landed on the hidden, real page
underneath, fully authenticated with their own session.
frame-ancestors is the fix: it tells the browser which
origins are allowed to embed this page in a frame at all, and
'self' (or omitting other origins entirely) makes the
invisible-iframe version of the attack impossible outright.
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
⚠ This isn't a toy example
A merge/clone utility that accepts any JSON from outside the
program (a request body, a config file) and doesn't guard against
this is a real, repeatedly-exploited vulnerability class — several
popular npm packages have shipped exactly this bug. The fix: skip
"__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.
npm ciin CI, notnpm install. It installs exactly what the lockfile says and fails ifpackage.jsondisagrees, so the build cannot drift.npm auditfinds known-vulnerable versions, not newly published malicious ones. It answers "is this old problem in my tree", not "is this release safe".- Provenance. A package published from CI with
npm publish --provenancecarries a signed statement linking it to a source repository and build.npm audit signaturesverifies those statements and the registry signatures for what you installed. - Install scripts run as you. A dependency's
postinstallexecutes on your machine and in your CI.--ignore-scriptsdisables them, and some package managers, such as pnpm 10, no longer run them for dependencies by default.
crypto.subtle — hashing and randomness, done correctly
const bytes = crypto.getRandomValues(new Uint8Array(16)); // cryptographically secure — unlike Math.random()
const data = new TextEncoder().encode("hello");
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashHex = [...new Uint8Array(hashBuffer)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
console.log(hashHex); // a real SHA-256 hash, no library needed
⚠ Math.random() is not safe for anything security-related
Math.random() is fast and fine for a game or a shuffle,
but it isn't cryptographically secure — its output can, in principle,
be predicted from enough samples. A session token, a password-reset
code, or anything else where guessing matters belongs behind
crypto.getRandomValues() (or
crypto.randomUUID() for a quick unique id), never
Math.random().
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
⚠ A missing origin check accepts messages from ANY page
Without the
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.
Dry run: the origin check against four incoming messages
| event.origin | event.origin !== "https://trusted-partner.example"? | Result |
|---|---|---|
| https://trusted-partner.example | false | passes — handleMessage(event.data) runs |
| https://evil.example | true | return — message ignored |
| http://trusted-partner.example (http, not https) | true | return — ignored, scheme is part of the origin |
| https://trusted-partner.example.evil.com | true | return — ignored, not an exact string match |
The check is a plain string comparison, not a "starts with" or
"contains" check — which is exactly why it has to be. A looser check
like .includes("trusted-partner.example") would let the
fourth row through, since that hostname genuinely does contain the
trusted string as a substring.
Subresource Integrity — trusting a third-party script
<script
src="https://cdn.example.com/lib.js"
integrity="sha384-oqVuAf...=="
crossorigin="anonymous">
</script>
integrity is a hash of the exact file the page expects —
the browser downloads the script, hashes it, and refuses to execute
it at all if the hash doesn't match. Without it, a compromised CDN (or
one tricked into serving a different file at the same URL) can
silently swap in malicious code that runs with full access to the
page, and nothing about the <script> tag itself
would look any different.
Trusted Types — closing the innerHTML hole at the platform level
Content-Security-Policy: require-trusted-types-for 'script'
const policy = trustedTypes.createPolicy("app-html", {
createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userInput); // allowed — went through the policy
el.innerHTML = userInput; // throws — a raw string, no policy involved
With require-trusted-types-for 'script' set, the browser
itself refuses to assign a plain string to innerHTML
(and a handful of other injection sinks) anywhere on the page — it
has to be a special TrustedHTML object that only a
registered policy can produce. This doesn't replace sanitizing; it
turns forgetting to sanitize into a hard build/runtime error
instead of a silent vulnerability, closing off the entire bug class at
the platform level rather than trusting every call site to remember.
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) |
Rule An
httpOnly cookie with
SameSite=Lax or Strict shuts the two easy
attacks at once — an XSS payload can't read the token and ship it
somewhere else, and cross-site requests don't carry it. It does
not make XSS harmless: injected script runs on your own
origin, so while the page is open it can still send requests that
carry the cookie. Prevent XSS first; where the token lives only limits
the damage. 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) => {
const base64 = part.replace(/-/g, "+").replace(/_/g, "/"); // base64url → base64
return JSON.parse(atob(base64));
};
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 — once base64url's
- and _ are swapped back to + and
/, which plain atob rejects. 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.
OAuth and PKCE — delegating login without ever seeing a password
"Sign in with Google" is OAuth: your app never sees the user's Google password at all. It redirects to Google, the user authenticates there, and Google redirects back with proof — an authorization code — that your app exchanges for tokens.
// 1. the app generates a random secret and its hash, BEFORE redirecting
const verifier = generateRandomString();
const challenge = await sha256Base64Url(verifier);
// 2. redirect to the provider, sending only the hash
location.href = "https://provider.example/authorize?" + new URLSearchParams({
response_type: "code",
client_id: "...",
code_challenge: challenge,
code_challenge_method: "S256",
});
// 3. provider redirects back with a code; exchange it, proving you hold the original secret
await fetch("/token", {
method: "POST",
body: new URLSearchParams({ grant_type: "authorization_code", code, code_verifier: verifier }),
});
PKCE (Proof Key for Code Exchange) exists because a public
client — a single-page app or a mobile app, with no server-side
secret it can actually keep secret — can't safely hold a fixed
client secret the way a traditional server-side app could. Sending
only the hash up front and the real verifier
only at the final, direct exchange step means an attacker who
intercepts the authorization code in transit still can't redeem it
without also having the original, never-transmitted verifier.
What logout actually does
⚠ Deleting a JWT client-side doesn't invalidate it
A stateless JWT is valid until it expires, full stop — the
server never tracked it, so there's nothing to revoke. "Logout"
deleting the token from the browser only stops that browser
from sending it; a copy captured earlier (a leaked log, an XSS
payload that already ran) is still fully valid until its
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.
Interview answer, one sentence
"Most incidents come from a few root causes — untrusted input rendered as code (XSS), a request made on the user's behalf (CSRF), and trust in third-party code — and the defence is layered: escape or sanitise at output, SameSite cookies and CSRF tokens, a nonce-based CSP, and locked, audited dependencies."
Before you move on
- Name the one root cause behind all three XSS flavors, and why
textContentfixes it. - Say what
SameSite=LaxvsStrictchanges about a cookie on a cross-site request. - Explain why the
"__proto__"merge bug pollutes every object, not just the one merged. - Say why an
event.origincheck rejects a spoofed message with a legitimate-looking payload. - Explain why deleting a JWT client-side doesn't revoke it, and what would.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series