Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

27 chapters
⌕
Beginner9›
B1Setup & mental modelB2Types & valuesB3Operators & flowB4Functions (first half)B5Objects & arrays (first half)B6DOM & eventsB7Basic asyncB8Errors & tools★Cheat page
Intermediate10›
I1Scope & functions, properlyI2Objects deeplyI3Prototypes & OOPI4Async, properlyI5Modules & toolingI6Regex, dates & APIsI7Error handlingI8Real-time connectionsI9Offline & storage★Cheat page
Advanced10›
A1Engine & memoryA2Advanced asyncA3MetaprogrammingA4Types & dataA5Patterns & architectureA6PerformanceA7SecurityA8EcosystemA9Testing★Cheat page
/ search[ ] chaptert top

JavaScript levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
A7

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

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 (most browsers' default today)only on top-level navigation (clicking a real link), not on a background fetch/form auto-submit
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.

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.

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.

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

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.

Practice this layer

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

Escape text before it becomes HTML4 tests · advancedFix the merge so it can't pollute Object.prototype2 tests · advancedDecode a JWT payload — no secret required1 test · advancedCheck token expiry with an injectable clock2 tests · advanced
←previousPerformance↑ CovernextEcosystem→