← back to the section

A frontend developer writes code that runs on somebody else's computer, in somebody else's browser, and gets data from a server that cannot be trusted blindly. While everything works, the borders between these three parties are invisible. They show up with the first blocked by CORS policy error, a token stolen from localStorage, or a button clicked from a foreign site. Let us look at what the browser does between typing an address and the screen, and which rules it applies to your code and your data.

From the address to the response

The user types an address and presses Enter. The browser checks its DNS cache, asks the system and the provider's resolver, and gets the server's IP address. Then it opens a TCP connection, negotiates TLS encryption on top of it, and only then sends the HTTP request: method, path, headers, sometimes a body. The server answers with a status, headers and a body, usually HTML. The browser parses the HTML, finds references to styles, scripts and images along the way and requests them the same way, and HTTP/2 lets dozens of such requests travel over one connection.

Two things matter here for the frontend. First: a page is not one request but dozens, and the order in which they load decides when the user sees content; the article on the critical rendering path covers that. Second: a repeat visit should not download everything again. The browser caches responses by headers: Cache-Control: max-age says for how many seconds a response is fresh, ETag lets the server reply 304 Not Modified without a body. That is why the bundle is built with a content fingerprint in the file name and cached for a year, while index.html is not cached at all: a new version changes the file names, and old ones stay in the cache harmlessly.

The same-origin policy and CORS

An origin is the scheme, the domain and the port together: https://shop.example and https://api.shop.example are different origins. The browser enforces a rule: a script from a page of one origin cannot read responses from another. Otherwise any site you opened could send a request to your bank with your cookies and read the balance.

CORS is the server's way of telling the browser "this origin may read". The server adds Access-Control-Allow-Origin with the page's address to the response, and the browser lets the response through to the code. Simple requests, GET and POST with ordinary headers, go out immediately and the response is filtered by that header. For requests with Content-Type: application/json, custom headers, or the PUT and DELETE methods, the browser first sends a preliminary OPTIONS request, the preflight, and waits for permission. Three consequences people confuse follow. A CORS error is the browser's decision: the server received the request and may even have executed it. CORS is fixed only on the server; no header on the client helps. And curl or Postman work without CORS because they are not browsers.

If a request must carry cookies to another origin, you need both credentials: "include" on the client and Access-Control-Allow-Credentials: true on the server, with a specific origin rather than a wildcard.

Cookies, localStorage and sessionStorage

The browser has three places for client-side data, and they answer different questions. localStorage keeps strings by key with no expiry until the user or the code removes them, and is available to any script on the same origin. sessionStorage is the same but lives until the tab closes, and each tab has its own. Neither ever travels to the server by itself.

Cookies work differently: the server sets them with the Set-Cookie header, or code sets them, and the browser attaches them to every request to that domain on its own. Cookies have attributes, and those decide the security questions. HttpOnly hides the cookie from JavaScript, so it cannot be stolen by an injected script. Secure allows sending only over HTTPS. SameSite=Lax or Strict forbids sending with requests initiated by a foreign site. Max-Age sets the lifetime.

Hence the answer to "where to store the token". A token in localStorage is readable by any script on the page, including one injected through a vulnerability or a third-party library, and from there it travels to the attacker. A token in a cookie with HttpOnly, Secure and SameSite is not available to scripts at all, and the browser attaches it itself. So the session or the refresh token goes into such a cookie, and if a short-lived access token is still kept on the page, it is kept in memory, in a variable, not in storage, and obtained again after a reload.

XSS: someone else's script on your page

Cross-site scripting is when a string from a user ends up in the page as markup. A comment containing <img src=x onerror="..."> inserted through innerHTML executes code for everyone who opens the page, with that page's privileges: it reads localStorage, sends requests on the user's behalf, replaces the login form.

Protection starts with a rule: data is inserted as text, not as markup. React escapes everything it renders in JSX, and a vulnerability appears only where that is bypassed by hand: dangerouslySetInnerHTML, an href with a javascript: value, insertion into <script> or into event attributes. User HTML, if it is really needed, goes through a sanitizer such as DOMPurify. The second layer is Content Security Policy: a header that lists where the page may load scripts from and forbids inline ones; with it an injected script does not run even if it got into the markup. The third layer is those HttpOnly cookies that make stealing a session through XSS impossible.

CSRF: a request on the user's behalf without their knowledge

Cross-site request forgery exploits the fact that the browser attaches cookies to any request to a domain. The user logged into the bank, then opened a foreign page, and that page submitted a form to POST /transfer on the bank: the cookies went along and the server executed the transfer. A script from the foreign page cannot read the response, but it does not need to, the action is already done.

The defense is twofold. SameSite=Lax on the cookie, today's default, does not send the cookie with a POST from a foreign origin; Strict does not send it with link navigations either. And a CSRF token: the server gives the page a random value, the form or a header returns it, and a foreign page does not know it because it cannot read the server's response. For an API where authorization travels in the Authorization header rather than a cookie, CSRF is not a threat: a foreign page cannot set that header.

When requests are not enough: WebSocket and SSE

HTTP is built as question and answer, and a chat, an exchange rate or task progress fit it badly: the page has to poll the server on a timer. There are two ways to receive data on the server's initiative. Server-Sent Events are an ordinary HTTP response that never ends while the server writes events into it; in the browser that is EventSource, which reconnects on its own, but data flows only from server to client. WebSocket is a separate two-way channel over the same TCP after a handshake, suitable for chat and games, but reconnection and authorization are up to you, and load balancers and proxies need configuration.

The rule of choice: notifications and server-driven updates, SSE; two-way exchange with low latency, WebSocket; everything else, ordinary requests. The details about the protocols, scaling and pitfalls are in the article on WebSocket and SSE.

In short

  • A page's path: DNS, TCP, TLS, the HTTP request, parsing HTML and dozens of resource requests; Cache-Control and ETag drive the cache, a fingerprinted bundle is cached for a year, index.html is not.
  • An origin is scheme, domain and port; the same-origin policy forbids reading foreign responses, CORS is the server's permission with a preflight OPTIONS for non-simple requests; it is fixed only on the server.
  • localStorage is permanent and shared across tabs, sessionStorage is per tab, cookies travel to the server on their own; HttpOnly, Secure and SameSite make cookies the place for the session and the refresh token.
  • XSS: insert data as text, dangerouslySetInnerHTML only after a sanitizer, CSP forbids foreign and inline scripts, HttpOnly prevents session theft.
  • CSRF: SameSite on cookies and a CSRF token; an API with the Authorization header is not subject to forgery.
  • SSE for a server-driven stream with automatic reconnection, WebSocket for two-way communication, ordinary HTTP for everything else.

Further reading