Web security
What the same-origin policy protects
An origin is scheme + host + port. The browser's baseline rule: a page from one origin cannot read another origin's data, meaning its DOM, its fetch responses, or its storage. Writes and embeds are allowed (submitting a form to another site, loading <img>/<script> from a CDN); reading back is what's guarded.
Every subtopic here hangs off that rule. XSS gets the attacker inside your origin, where the rule no longer protects you. CSRF abuses the cross-origin writes the rule allows, CORS is a server relaxing the read block on purpose, and CSP is damage control for when the boundary fails.
How XSS works in a React app
If attacker-controlled script executes in your page, the game is over. That script is your origin: it reads the DOM, uses the session, and calls your API as the logged-in user.
React and Vue escape interpolated values by default, so {userBio} renders as plain text. That kills the classic <script> injection. The escape hatches are where the incidents (and the interview questions) concentrate:
<div>{userBio}</div> // safe — rendered as text
<div dangerouslySetInnerHTML={{ __html: userBio }} /> // raw HTML, XSS if unsanitized
<a href={user.website}>my site</a> // javascript:alert(1) runs on click
The href line is the trap. Escaping does nothing there, because the value is a perfectly legal attribute string; the protocol itself is the payload. Validate URLs yourself, the framework won't.
❌ "We use React, so XSS is solved." React solves one injection context. dangerouslySetInnerHTML, attacker-controlled URLs, and direct DOM APIs are all still yours to guard.
How CSRF works
The browser attaches your site's cookies to requests going to your site, and historically it did so no matter which page triggered them. So an attacker's page auto-submits a hidden form to bank.example/transfer, and the request arrives carrying the victim's real session cookie.
❌ "CSRF is about stealing the session cookie." The attacker never sees the cookie and doesn't need to; the browser does the attaching. The defense is provenance: proving the request came from your own pages. The SameSite cookie attribute (modern default: Lax) blocks most cross-site sending. How much it leaves open is middle-level material.
Why the browser blocks your fetch with a CORS error
When your fetch to another origin fails with "blocked by CORS policy", the browser enforced the same-origin default because the server didn't opt in. Two things juniors get backwards:
Access-Control-Allow-Originis a response header. Only the server can grant access; nothing you add to the fetch call helps.- CORS won't shield the server from anything. It exists only in the browser:
curlignores it entirely, and the request often reached the server anyway. What got blocked is your JS reading the response.
Cookie flags and JWT structure
Session cookies should carry HttpOnly (JS can't read them) and Secure (HTTPS only). A JWT is three base64url parts, header.payload.signature, and it is signed, not encrypted: anyone can decode the payload with atob. The signature proves the token wasn't tampered with; it keeps nothing secret.
Stored, reflected, and DOM-based XSS
Stored (payload persisted server-side, served to everyone), reflected (payload echoed back from the request), DOM-based (never touches the server: a source like location.hash or a postMessage payload flows into a sink like innerHTML, document.write, eval, script.src). DOM XSS is the one the frontend owns; no backend fix exists for it.
The output decision people blur is escaping vs sanitizing. Escaping encodes data so it stays text, and it is context-specific: HTML body, attribute, and URL each encode differently. Sanitizing (DOMPurify) keeps markup but strips executability, for the one case where users legitimately author HTML (rich-text editors). ❌ Treating them as synonyms picks the wrong tool: escaping a WYSIWYG output breaks the feature, and "sanitizing" a username that should never be HTML adds complexity where escaping was enough.
How nonce-based CSP works
Modern CSP is nonce-based: a fresh random value per response, echoed on every legit script tag.
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none'; base-uri 'none'
Injected <script> has no nonce → doesn't run. 'strict-dynamic' lets a nonced script load further scripts (bundler chunks, tag managers) without listing them. Once a nonce is present, browsers ignore 'unsafe-inline', so you can ship both for old browsers. Roll out via Content-Security-Policy-Report-Only and watch violation reports before enforcing.
Why not domain allowlists? They fail in practice: allowed CDNs host JSONP endpoints and old library versions that echo attacker input, and real allowlists balloon until they're meaningless. A nonce makes "may this script run" a per-response decision instead of a standing invitation.
❌ "We have CSP, XSS is handled." CSP blocks the script-execution stage. The injection bug is still there: HTML injection (fake login forms), exfiltration through allowed channels, and anything your own trusted code does with attacker data are all untouched.
When a CORS request triggers a preflight
A simple request (GET/HEAD/POST with only safelisted headers and one of three content types: application/x-www-form-urlencoded, multipart/form-data, text/plain) is sent immediately, cookies and all. No preflight. Anything else (a PUT, a Content-Type: application/json, any custom header) triggers a preflight: the browser sends OPTIONS asking permission before the real request, and caches the answer per Access-Control-Max-Age.
Credentialed requests (credentials: "include") tighten everything. The server must send Access-Control-Allow-Credentials: true, wildcards are forbidden, and Access-Control-Allow-Origin must echo the exact origin.
The security consequence: a simple POST reaches your server with cookies attached regardless of CORS. ❌ CORS is no CSRF defense; it never blocked the sending.
What SameSite=Lax blocks and what it lets through
Lax sends the cookie on top-level navigations with safe methods and blocks it on subresources and cross-site POSTs. That leaves real gaps: any state-changing GET endpoint is forgeable with a plain link; sibling subdomains are same-site, so one compromised subdomain forges freely; and Chromium temporarily exempts cookies younger than 2 minutes from Lax on POST. Production defense stacks: SameSite plus a CSRF token or a custom header (a cross-site form can't set one, and a fetch that does triggers preflight), plus Origin verification server-side.
Where tokens live and how they get stolen
localStorage is readable by any script in the origin: one XSS and the token is exfiltrated and replayed offline until expiry. An httpOnly cookie is unreadable by JS, so theft is out, but it's auto-attached, so CSRF returns, and injected script can still use the session from inside the page. Neither choice defends against XSS; they choose the blast radius. The middle-ground pattern: short-lived access token in memory, refresh token in an httpOnly cookie.
JWT validation is where backends get burned, and interviewers check that frontend candidates know it too. The server must pin the algorithm (never trust the token's alg header; alg:none and HS256/RS256 confusion attacks live there), verify the signature, and check exp, iss, aud. And once more: the token is signed, not encrypted. The payload is public.
Surviving the XSS bug you will eventually ship
Assume the XSS bug ships eventually, and design so it's survivable. Layers, in order of engagement: escaping and sanitizing at the source, Trusted Types turning DOM sinks into type errors, strict CSP catching what slips through, short-lived credentials capping what a successful attack buys.
Trusted Types is the structural layer. require-trusted-types-for 'script' makes innerHTML, document.write, eval, script.src refuse raw strings; they demand TrustedHTML/TrustedScript objects minted by an explicit policy (trustedTypes.createPolicy). DOM XSS stops being "grep every sink" and becomes "audit three policy functions". Supported across engines now; roll out report-only, fix violations, enforce. For content you can't make trustworthy (user HTML previews, third-party widgets), a sandboxed iframe on a separate origin is honest containment.
Where access and refresh tokens should live
The pattern that survives review: access token in memory only with a short TTL, refresh token in a locked-down cookie scoped to exactly one endpoint:
Set-Cookie: __Host-refresh=...; Secure; HttpOnly; Path=/auth/refresh; SameSite=Strict
Refresh rotation with reuse detection: every refresh issues a new token and invalidates the old; a replayed old token means two parties hold the family, so revoke it all. The stronger move is the BFF pattern: a thin backend does the OAuth dance and holds all tokens, and the browser only ever has a session cookie. Nothing token-shaped exists in the client to steal; you've traded the storage debate for classic, well-understood cookie hygiene.
Remember what httpOnly buys: it stops exfiltration, not abuse. Script inside the page can still drive the API as the user, which is why TTLs and rotation matter more than the storage debate itself.
Why the OAuth implicit flow died
The implicit flow handed the access token straight back in the URL fragment: readable by scripts, parked in browser history, with no client authentication and no sane refresh story. The authorization-code flow moves the token to a back-channel exchange, and PKCE closes the remaining gap for public clients: the app hashes a random code_verifier into a code_challenge at the start, then must present the verifier at exchange, so an intercepted code alone is worthless. OAuth 2.1 codifies the verdict: implicit and password grants removed, PKCE mandatory, redirect URIs matched exactly, refresh tokens for public clients sender-constrained or single-use. In interviews, a new SPA login designed on the implicit flow "because it's simpler" reads as a red flag.
Third-party scripts and postMessage
Third-party scripts are XSS by invitation. A <script src> you include runs with full origin authority; CSP won't save you from code you allowlisted, and an npm compromise is in your bundle before any browser control engages. Mitigations are partial and honest: SRI (integrity=...) pins static CDN assets, vendor widgets go in sandboxed iframes, lockfiles plus provenance checks cover the supply chain, and the review question is "what can this vendor's worst day do to us?"
postMessage is a cross-origin door you opened on purpose. Always verify event.origin on receive (an addEventListener("message") without an origin check is a finding, full stop), and never send sensitive data to "*".
What to probe in a design review
Four questions cover this module. Where does user input meet a sink, and which layer catches it when the first one fails? Who can make this request, and what proves its provenance beyond a cookie? What does a stolen token buy, and for how long? Which third-party code runs in our origin, and what pins it?
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.