Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A7
35 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA7 · Part 3 · Advanced

Security

The mistakes that turn into a real incident, not just a bug.

14 min read16 sections4 exercises
35 of 41

In this chapter

  • 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

and 11 more

about 14 min
16On this page
  1. XSS — three flavors, one root cause
  2. CSRF, SameSite, and CORS — three names for "who's actually making this request"
  3. The CORS preflight — the request browsers send before yours
  4. Clickjacking — hijacking a click the user thinks landed elsewhere
  5. Prototype pollution
  6. Supply-chain risk
  7. crypto.subtle — hashing and randomness, done correctly
  8. innerHTML and postMessage, safely
  9. Subresource Integrity — trusting a third-party script
  10. Trusted Types — closing the innerHTML hole at the platform level
  11. Auth in practice: where does the token actually live?
  12. JWT — signed, not encrypted
  13. Session vs token auth
  14. Refresh tokens
  15. OAuth and PKCE — delegating login without ever seeing a password
  16. 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
Storedthe database — a comment, a username, a bio someone else submitted, rendered later for other visitors
Reflectedthe current request — a URL/search-query parameter echoed straight into the page's HTML
DOM-basedclient-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?
Strictnever
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
Nonealways — 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 ci in CI, not npm install. It installs exactly what the lockfile says and fails if package.json disagrees, so the build cannot drift.
  • npm audit finds 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 --provenance carries a signed statement linking it to a source repository and build. npm audit signatures verifies those statements and the registry signatures for what you installed.
  • Install scripts run as you. A dependency's postinstall executes on your machine and in your CI. --ignore-scripts disables 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.originevent.origin !== "https://trusted-partner.example"?Result
https://trusted-partner.examplefalsepasses — handleMessage(event.data) runs
https://evil.exampletruereturn — message ignored
http://trusted-partner.example (http, not https)truereturn — ignored, scheme is part of the origin
https://trusted-partner.example.evil.comtruereturn — 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 JavaScriptyes — including any injected XSS payloadno — invisible to JS entirely, by design
Sent automatically on every matching requestno — you attach it yourselfyes — which is what makes CSRF possible against it
Vulnerable toXSS (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 keepsa session store (Redis, DB) mapping an id to who's logged innothing — the token itself carries the claims
Checking a requestlook the session id up in the storeverify the signature — no lookup, no shared store needed
Revoking access instantlydelete the session server-side, donenot until it naturally expires — see logout, below
Scales across serversneeds a shared session storetrivially — 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 textContent fixes it.
  • Say what SameSite=Lax vs Strict changes 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.origin check rejects a spoofed message with a legitimate-looking payload.
  • Explain why deleting a JWT client-side doesn't revoke it, and what would.

04Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 11 testsEscape text before it becomes HTMLadvanced
  • 9 testsFix the merge so it can't pollute Object.prototypeadvanced
  • 8 testsDecode a JWT payload — no secret requiredadvanced
  • 8 testsCheck token expiry with an injectable clockadvanced
All 31 for this level →
A7

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A6Previous · A6Performance
  2. A7you are here
  3. Next · A8Ecosystem & professionalHow the tools around JS actually work, and where the language itself comes from.8 min read

01On this page

16 sections · 0%

  1. 01XSS — three flavors, one root cause
  2. 02CSRF, SameSite, and CORS — three names for "who's actually making this request"
  3. 03The CORS preflight — the request browsers send before yours
  4. 04Clickjacking — hijacking a click the user thinks landed elsewhere
  5. 05Prototype pollution
  6. 06Supply-chain risk
  7. 07crypto.subtle — hashing and randomness, done correctly
  8. 08innerHTML and postMessage, safely
  9. 09Subresource Integrity — trusting a third-party script
  10. 10Trusted Types — closing the innerHTML hole at the platform level
  11. 11Auth in practice: where does the token actually live?
  12. 12JWT — signed, not encrypted
  13. 13Session vs token auth
  14. 14Refresh tokens
  15. 15OAuth and PKCE — delegating login without ever seeing a password
  16. 16What logout actually does

[] chapters · t top