Field guide · client-side web security
Your browser is a
shared space.
Every site you have open runs in the same browser, next to your bank tab. Web security is the set of locks that stop one site from abusing another — or from running code it shouldn’t. Here’s the whole toolkit, in plain English, with a diagram or a sandbox for each. No prior security knowledge needed.
01Origin vs site — the two boundaries
in one line an origin is scheme + host + port and must match exactly; a site is looser — the same registrable domain, whatever the subdomain or port.
Almost every rule below is enforced at one of two boundaries, and they are not the same. Getting them mixed up is where most “why isn’t my cookie shared?” bugs come from. Compare a pair:
httpsscheme://app.example.comhost:443port/api/datapath
ORIGIN
scheme + host + port — all three must match exactly.
https://app.example.com:443this exact host, this exact port
governs: reading a fetch() reply · DOM access between frames · localStorage
SITE
scheme + registrable domain — port ignored, subdomains collapsed.
https://*.example.comany subdomain, any port
governs: the SameSite cookie attribute · the Sec-Fetch-Site header
mind the asteriskRead that wildcard as *.<registrable domain>, not *.domain.tld. Those usually agree — but “the last two labels” is a guess, not the rule. Under co.uk the site is *.example.co.uk, never *.co.uk; and on alice.github.io there is no wildcard at all. Where the * lands is decided by the list below, not by counting dots.
Compare a pair and watch both verdicts:
https://app.example.comvshttps://api.example.comThe everyday case. Different host → different origin, so CORS applies to every fetch between them. But both collapse to the same registrable domain example.com, so they are the same site — and a cookie can be shared across them without ever touching SameSite=None.
shop.example.co.uk
shopsubdomainexampleone more labelco.ukpublic suffix — on the PSLregistrable domain = the SITE
The registrable domain is one label more than the public suffix, never “the last two labels”. Counting labels would give co.uk — and then every business registered under co.uk would be one site, free to set each other’s cookies. It cuts the other way too: github.io is itself on the list, so alice.github.io and bob.github.io are separate sites.
the practical upshotYour front-end host calling your API host is cross-origin, same-site. That needs a CORS allow-list and a cookie Domain attribute — not SameSite=None. Loosening SameSite there buys nothing and opens the cross-sitefetch() you wanted blocked.
02Navigation vs subresource — did the address bar change?
in one line a top-level navigation moves the whole tab and you can see where you landed; a subresource request happens invisibly, and the page that fired it reads the reply.
Every SameSite explanation leans on the phrase “top-level GET navigation” and never defines it. It’s the distinction the whole feature is built on — so here it is, side by side. Step both columns together:
TOP-LEVEL NAVIGATIONthe address bar changes
<a href="bank.com"> click me </a>
You SEE where you landed.
evil.com cannot read this page.
SUBRESOURCE REQUESTthe address bar stays put
<script>
fetch("bank.com/balance",
{credentials:"include"})
</script>You see nothing at all.
The attacker reads the response.
why this is the whole ruleOn the left the attacker only sent you somewhere — the page that renders is one their JavaScript cannot read, because of the same-origin policy. Sending your cookie there is fairly harmless. On the right their own script reads the reply. That asymmetry is exactly whereSameSite=Lax draws its line: cookie sent on the left, withheld on the right.
03Cookies — the wristband
in one line a cookie is a wristband that says “this is a logged-in user”; three flags decide who can copy or abuse it.
A cookie proves you’re logged in. Three flags protect it, each against a different attack. Set them below and watch what each choice blocks or exposes — the Set-Cookie line is exactly what you’d send.
SameSite=Laxcross-site → withheld✓ blockedHttpOnly=truedocument.cookie is empty✓ blockedSecure=trueHTTPS only✓ blockedThe recommended login cookie. It blocks CSRF, XSS token theft, and wire sniffing, with no usability cost. This is the one to ship.
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Laxthe rule of thumbFor a normal login cookie, ship Secure; HttpOnly; SameSite=Lax. Reach up to Strict for admin/banking; down to None only for genuine cross-site needs (and then add a CSRF token).
04SameSite — Strict, Lax, None in full
in one line three places to draw one line: how far the cookie still rides when the request comes from another site.
With both boundaries in hand, the three values are simple. Pick one, then pick a situation — the matrix stays on screen so you read the cell in context:
Cookie SENT — A top-level GET navigation: the address bar changed, and whoever sent you cannot read the page that renders. Lax judges that harmless enough to send, so the user arrives logged in.
SameSite=Lax“Also when the user clicks their way in.” Blocks every background request while keeping link clicks and login redirects working. This is the browser default when you set no attribute at all, and the right answer for roughly every session cookie.
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Laxthe trap that breaks loginsAn SSO / OIDC flow sets a short state / PKCE cookie, sends the user to the identity provider, and the provider redirects them back to your /callback. That last hop is a top-level GET navigation arriving from another site. UnderStrict the cookie is withheld, the callback can’t verify its own PKCE challenge, and login fails. Under Lax it works — that case is exactly what Lax was designed for.
05XSS — someone else’s script on your page
in one line an attacker sneaks JavaScript onto your page; it runs as you and can read the DOM, cookies, and act on your behalf.
XSS is the big one. The classic delivery is a stored comment that’s saved raw and rendered unescaped. Step through it:
the fix — 3 locks, any one breaks the chainEscape output so the script becomes inert text · a strict CSP (no 'unsafe-inline') so no injected inline script runs · HttpOnly=true so even if a script runs, document.cookie is empty and there’s nothing to steal.
Two things almost every write-up of this gets wrong — and the second one changes what you go and fix:
“You visit attacker.com, and it runs code on your bank.”
No — you go straight to the real site, and the real site hands you the script. In a stored XSS the attacker just filled in a comment box like any other user, days earlier. Nobody visits evil.com; it appears only at the very end, as somewhere to send the stolen token. What the myth describes is either CSRF or the year-2000 frame attack browsers killed.
“XSS breaks the same-origin policy.”
It never breaks it — it satisfies it. The script really was served inside your page by your own server, so its origin really is yours, and the browser is right to grant it your privileges. Nothing is bypassed; the attacker is legitimately promoted. (OWASP’s “bypasses access controls” is fair shorthand for the effect — but there is no defect in the SOP being exploited.)
the analogyThe same-origin policy is the bouncer checking wristbands at the door, and the bouncer is doing a flawless job. XSS isn’t sneaking past them — it’s the club’s own printer being tricked into printing a real wristband for the attacker. The band is genuine. The bouncer is right to accept it. The failure happened in the back office, where untrusted text got printed into a place the browser is obliged to read as code.
Which is why evil.com is absent from all three deliveries:
why the distinction changes your fixIf you believe the SOP is broken, you go looking for a browser or infrastructure fix — and there isn’t one, because the browser is behaving correctly. The bug is in your own output path: escape on output, ship a strict CSP, and keep the cookie’s Domain narrow so one compromised host isn’t the whole company.
One more thing, because the name actively misleads: it points at a boundary that isn’t the one being crossed. Three tabs, three different answers —
what the acronym claims
cross-site
Coined around 2000, when it described a genuinely cross-site attack: an attacker’s page loading a victim site in a frame and scripting across that frame boundary. Browsers closed that specific hole by enforcing the same-origin policy on frames.
The name outlived the attack it named and got re-attached to script injection — a different thing. “HTML injection” or “script injection” would be far more accurate.
the one-line versionXSS is named cross-site, executes same-origin, and spreads same-site. That one line explains why SameSite can’t help here, why CORS can’t either, and why sharing a cookie across a whole parent domain costs more than it looks.
06CSRF — a forged request that rides your session
in one line another site makes your browser fire a request at a site you’re logged in to; your cookie tags along and the action runs as you.
CSRF needs no script on your page and steals nothing — it borrows your session for one request. Step through it:
the twistevil.com never reads your cookie. Your browser attaches it automatically. It rides your logged-in session; it doesn’t steal it. (Reading the value is XSS’s job — above.)
the fix — one wordSet SameSite=Lax (already the browser default) or Strict. At step 4 the browser sees a cross-site POST and withholds the cookie — the bank gets an anonymous request and replies 401. Same attack, dead.
07CSP — the safety net under XSS
in one line a Content-Security-Policy header whitelists where scripts, styles, and frames may come from — so even an injected <script> won’t run.
A CSP is your backstop: escaping can slip, but CSP still refuses to run code from anywhere you didn’t allow. Here are the everyday directives — then try a script-src value:
default-srcthe fallback for any type you didn’t listscript-srcwhere scripts load — and whether inline runsstyle-srcwhere stylesheets + inline styles may come fromimg-srcwhere images may load fromconnect-srcwhere fetch / XHR / WebSocket may talk toframe-srcwhat YOU may embed in a frame (§09)frame-ancestorswho may embed YOU in a frame (§09)Now pick a script-src value and watch which scripts the browser lets run:
Content-Security-Policy: script-src 'self' 'nonce-r4nd0m'The sweet spot: your scripts + inline scripts carrying the matching nonce. An injected <script> has no nonce, so it’s refused.
08Nonce — allowing inline script safely
in one line a random one-time token that marks YOUR inline scripts as trusted, so an injected script (which can’t guess it) is refused.
Sometimes you truly need an inline <script>. Instead of 'unsafe-inline' (which trusts everything), stamp your own scripts with a nonce:
Content-Security-Policy: script-src 'self' 'nonce-r4nd0m'<script nonce="r4nd0m">…</script>nonce matches → runs<script>steal()</script>no nonce → refusedwhy nonce beats 'unsafe-inline''unsafe-inline' trusts every inline script — including injected ones. A nonce trusts only the inline scripts you stamped this request. Same convenience (inline scripts still work), none of the risk. Use a nonce whenever you genuinely need inline script.
the grown-up version — 'strict-dynamic'Add 'strict-dynamic' and the nonce stops being just an inline-script trick: any script you noncedly trust may load further scripts, and the browser ignores your host allow-list entirely. That’s the point — host allow-lists age badly (a CDN you allowed today hosts someone’s JSONP endpoint tomorrow, and your policy is bypassed). One nonce, no allow-list to maintain, and it keeps working when a vendor changes their bundle — see the SRI section for what that costs you.
09Framing & clickjacking — who can put you in an iframe
in one line clickjacking hides your real site in an invisible iframe over a fake button; you decide who may frame you with frame-ancestors.
Two directions, easy to mix up: frame-src = what YOU embed; frame-ancestors = who embeds YOU. The second stops clickjacking:
frame-srcwhat YOU frame. Which sites your page may embed in an iframe.frame-ancestorswho frames YOU. Which sites may embed your page. The clickjacking defence.evil.com tries to load bank.com in an invisible iframe over a fake button. Pick your header:
Blocked. frame-ancestors 'self' tells the browser only your own site may frame you, so evil.com’s iframe renders nothing. This is the modern clickjacking fix.
10CORS — how a hacker leverages a loose policy
in one line CORS decides whether another site may READ your API’s response; misconfigure it and evil.com reads your logged-in users’ data.
CORS is widely misunderstood — it’s not a lock on the request, it’s a rule on who may read the reply. Here’s the attack when it’s too loose:
the pointCORS doesn’t block the request — your server still runs it. CORS only decides whether the browser lets the other site read the response. The attack is a server that says “yes, anyone can read this” while also accepting cookies.
the fixAllowlist exact origins — never reflect the Origin header back, and never pair Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true(the browser forbids that combo for a reason). List only the origins you trust.
11SRI — pinning the code your CDN serves
in one line Subresource Integrity adds a hash of the exact file you expect; if a hacked CDN swaps it, the hash won’t match and the browser blocks it — and it blocks a legitimate update just as hard.
You don’t control the CDN serving your lib.js. SRI makes that safe — pin the file’s hash. Then try the third toggle, the one most write-ups skip:
<script src="https://cdn.example.com/lib.js" integrity="sha384-oqVuAfXR…KM9Nyu" crossorigin></script>sha384-oqVuAfXR…KM9Nyusha384-oqVuAfXR…KM9NyuByte-for-byte what you pinned, so it runs. Your users are safe even though you don’t control the CDN — that is the whole point of SRI.
the cost — SRI fails closedLook at the last two states: they are the same outcome. The browser cannot tell an attack from an upgrade, so pinning converts “someone else changed my dependency” into “my page is broken”, and you have handed your availability to a vendor’s release schedule. Two traps make it worse: a mutable URL (/latest/lib.js, @1) is guaranteed to break eventually, and a cross-origin subresource silently needs crossorigin="anonymous" or the browser blocks it with no hash involved at all.
how to pay it1. Self-host. The real answer for most assets — you control updates, there is no third party to trust, and it is faster (no extra DNS + TLS handshake). This site self-hosts its fonts for exactly that reason. 2. Pin an immutable URL. …/[email protected]/dist/x.min.js never legitimately changes, so SRI never legitimately breaks; an upgrade becomes a deliberate PR that edits the URL and the hash together. 3. Ship a fallback — onerror on the tag, loading a local copy, so a mismatch degrades instead of disappearing.
when NOT to pinSome vendors require that you don’t. Stripe tells you to load js.stripe.com/v3 live and un-pinned, because they need to push security fixes to every integration the same day. Pinning a payment library would freeze you on a version with a known hole. For that class of dependency the honest answer is: you are trusting the vendor, so choose vendors you can trust — and reach for a nonce instead (next note).
Generate the hash for any file you host elsewhere:
openssl dgst -sha384 -binary lib.js | openssl base64 -A“can a nonce rescue this?” — partly, and know what you tradeThey answer different questions, so they are not substitutes. SRI asks “is this the exact file I reviewed?” — integrity of content. A nonce asks “did my own server put this tag on the page?” — authenticity of inclusion.
So a nonce does fix the breakage: with script-src 'nonce-r4nd0m' 'strict-dynamic' you stop pinning bytes and stop maintaining a host allow-list, the vendor updates freely, and an injected <script> is still refused because the attacker can’t guess the nonce. Your XSS protection is intact and your page stops breaking.
What you give up is precisely the thing SRI was bought for: if that CDN is compromised, the nonce’d tag loads the malicious file happily — the tag is genuinely yours, so the nonce is genuinely valid. A nonce cannot see inside the file. Nonce defends against an attacker injecting a tag into your page; SRI defends against the file at the other end of a tag you wrote. Pick by which threat is real for that asset — and for anything you can self-host, self-host it and the question disappears.
12HSTS — force HTTPS, kill the downgrade
in one line HSTS tells the browser “only ever talk to me over HTTPS”, so an attacker can’t strip the first http request down to cleartext.
HTTPS encrypts the wire — but only if you actually reach it. HSTS stops an attacker downgrading your first plain request. Step through the attack:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadthe fix — HSTSOnce the browser has seen this header, it rewrites http:// → https:// itself, before the request ever leaves your machine. The plain request never hits the wire, so there’s nothing to strip.
the first-visit gap → preloadHSTS only kicks in after the first successful HTTPS response — a brand-new device’s very first request is still exposed. preload ships your domain in the browsers’ built-in HSTS list so HTTPS is forced even then; includeSubDomains extends the rule to every subdomain.
13The headers, all together — a ship-it baseline
in one line one response can carry every lock at once; here’s the exact header set this very site sends.
Each lock above is one HTTP response header. Set them together and you’ve covered the common client-side attacks — including the three quiet ones: X-Content-Type-Options, Referrer-Policy, and Permissions-Policy.
Content-Security-Policywhitelists where scripts / styles / frames may load fromstops XSS · injection · §07Strict-Transport-Securityforces HTTPS for a year (+ preload)stops downgrade · sslstrip · §12X-Content-Type-Options: nosnifftrust the declared Content-Type, don’t guessstops MIME-sniff → scriptPermissions-Policyswitch off camera · mic · geolocationstops feature abuseInside the CSP, four more quiet directives finish the job: object-src 'none' (no Flash/plugins), base-uri 'self' (no hijacked <base>), form-action 'self' (forms can’t POST elsewhere), and upgrade-insecure-requests.
This is the exact security block this site sends on every response — a copy-paste baseline:
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' …;
style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: …;
connect-src 'self' …; frame-ancestors 'none'; base-uri 'self';
form-action 'self'; object-src 'none'; upgrade-insecure-requestsverify it yourselfOn any site, open DevTools → Network → click the document request → Response Headers. Or from a terminal: curl -sI https://www.pangaea.id | grep -i -E 'strict|content-security|x-frame|referrer|permissions'.
·Glossary
- Cookie
- A small piece of data the server asks your browser to store and send back on every request to that site — most often a session token that says "this is a logged-in user". Like a wristband at a club.
- Same-origin
- An "origin" is the scheme + host + port (https://bank.com:443) — all three must match exactly, so api.example.com and www.example.com are as foreign to each other as example.com and evil.com. The browser’s core rule: code from one origin can’t freely read data from another. Reading a fetch() response, DOM access between frames, and localStorage are all origin-scoped. Most web-security features are about carefully poking holes in this rule — safely.
- Site
- A looser boundary than origin: the scheme + the registrable domain, with the port and every subdomain ignored. app.example.com and api.example.com are cross-origin but same-site — which is why a cookie can be shared between them while SameSite still blocks evil.com. The registrable domain is one label more than the public suffix, not simply the last two labels.
- Public Suffix List
- A hand-maintained list of suffixes under which anyone may register a name — .com, .co.uk, and also github.io. Browsers use it to work out where the registrable domain starts. It stops every business under co.uk from counting as one site, and it stops one GitHub Pages user from setting cookies for another.
- Schemeful same-site
- The modern rule that counts http:// and https:// as two different sites, so a cookie set over HTTPS isn’t sent to the plain-HTTP version of the same domain. Older browsers ignored the scheme, which let an attacker on the network plant cookies the HTTPS site would then trust.
- Cross-site
- A request or script that involves a different site than the one you’re on. evil.com making your browser talk to bank.com is cross-site. Careful: cross-site is not the same as cross-origin — two subdomains of one domain are cross-origin (so CORS applies) yet same-site (so SameSite is happy).
- Subresource request
- A request a page fires while staying exactly where it is — fetch, XHR, an <img>, a <script>, an <iframe>. The address bar never moves, nothing on screen tells you it happened, and the page’s own JavaScript can read the reply. SameSite=Lax withholds the cookie from every one of them.
- Sec-Fetch-Site
- A header the browser adds to every request saying how it was triggered: same-origin, same-site, cross-site, or none (typed straight into the address bar). Script can’t forge it, so rejecting state-changing requests that arrive cross-site is a couple of lines of server code and a genuinely independent layer under SameSite.
- Domain attribute
- The Set-Cookie field that widens a cookie from one host to a whole domain suffix — Domain=.example.com sends it to every subdomain. Its own rule, neither origin nor site: Domain decides which hosts RECEIVE the cookie, SameSite decides which requests CARRY it. Widening it also widens your blast radius.
- SameSite
- A cookie flag that controls whether the cookie is attached to requests coming from other sites. Lax (the default) blocks it on cross-site form-posts; Strict blocks even link-clicks; None sends it everywhere (and hands CSRF risk back to you).
- HttpOnly
- A cookie flag that hides the cookie from JavaScript. With it on, document.cookie can’t read the session token, so a script injected via XSS has nothing to steal.
- Secure
- A cookie flag that only lets the cookie travel over HTTPS, never plain HTTP — so it can’t be read off the wire by someone sniffing the network.
- CSRF
- Cross-Site Request Forgery. Another site makes your browser fire a request at a site you’re logged in to; your cookie rides along automatically and the forged action runs as you. The attacker never reads the cookie — the browser attaches it. Blocked by SameSite.
- XSS
- Cross-Site Scripting. An attacker gets their JavaScript to run on your page (via an un-escaped comment, URL, or dependency). It runs as your site and can read the DOM, cookies (unless HttpOnly), and act as you. Stopped upstream by escaping + CSP.
- CSP
- Content Security Policy — an HTTP header that whitelists where the page may load scripts, styles, images, and frames from. Its main job: even if an attacker injects a <script>, CSP refuses to run it. A safety net under XSS.
- Directive
- One rule inside a CSP, naming a resource type and its allowed sources — e.g. script-src 'self' means "only run scripts served from my own origin". default-src is the fallback for any type you didn’t list.
- Nonce
- A random, single-use token the server puts in the CSP header AND on each trusted inline <script nonce="…">. The browser runs only inline scripts whose nonce matches — so a script the attacker injected (with no matching nonce) is refused. The safe way to allow inline script.
- 'unsafe-inline'
- A CSP source that permits ANY inline <script> or style="…" to run. Convenient, but it defeats CSP’s XSS protection — an injected inline script runs too. Replace it with a nonce or a hash.
- Clickjacking
- Loading your real site inside an invisible iframe on evil.com and tricking the user into clicking a hidden button (e.g. "delete account"). Stopped by telling the browser who’s allowed to frame you.
- frame-ancestors
- The CSP directive that says WHO may put your page in a frame/iframe — frame-ancestors 'self' means "only my own site". The modern replacement for the older X-Frame-Options header, which only allowed one value.
- frame-src
- The CSP directive for the opposite direction — WHAT your page is allowed to load inside a frame (which embeds you allow). frame-src controls what YOU frame; frame-ancestors controls who frames YOU.
- CORS
- Cross-Origin Resource Sharing. By default the browser blocks a script on site A from reading a response from site B. CORS is B’s way of saying "these specific origins may read my responses". Misconfigure it and any site can read your users’ data.
- SRI
- Subresource Integrity. When you load a script from a CDN, you add integrity="sha384-…" — a hash of the exact file you expect. If the CDN is hacked and serves a different file, the hash won’t match and the browser refuses to run it. The cost: it fails closed and cannot tell an attack from an upgrade, so a legitimate vendor update over the same URL breaks your page just as hard. Pin immutable, versioned URLs — or self-host.
- 'strict-dynamic'
- A CSP source that says: trust whatever a script I already trusted chooses to load, and ignore my host allow-list entirely. Paired with a nonce it is the modern recommended CSP, because host allow-lists age badly and are routinely bypassable — but it trusts by provenance, not by content, so it is not a substitute for SRI.
- HSTS
- HTTP Strict Transport Security — a response header (Strict-Transport-Security) that tells the browser “for the next N seconds, only ever reach me over HTTPS”. After the first visit the browser upgrades http:// to https:// itself, so an attacker can’t downgrade the connection to sniff it. Add preload to close the first-visit gap.
- X-Content-Type-Options
- The header X-Content-Type-Options: nosniff tells the browser to trust the declared Content-Type and not “sniff” a file’s bytes to guess its type — which stops a text or image upload from being reinterpreted and run as a script.
- Referrer-Policy
- Controls how much of the current URL is sent in the Referer header when you click a link or load a resource on another site. strict-origin-when-cross-origin sends only the origin (not the path or query) cross-site, so a secret sitting in a URL doesn’t leak.
- Permissions-Policy
- A header that switches off powerful browser features you don’t use — camera, microphone, geolocation — so even injected code (or an embedded iframe) can’t prompt for or use them.
- upgrade-insecure-requests
- A CSP directive that tells the browser to rewrite any http:// subresource on the page to https:// automatically — a safety net against a stray mixed-content link.
- JWT
- A signed token that proves who a user is. Often stored in a cookie; the server verifies its signature instead of looking you up every time.