What a browser does

A browser looks simple — type an address, see a page — but it is one of the most complex programs on a computer. For each page it:

  1. Fetches: resolves the name with DNS, opens TCP and TLS connections, sends HTTP requests and handles redirects, cookies and caching (see How the Web Works).
  2. Parses HTML into a tree of objects in memory. Every element, attribute and piece of text becomes a node.
  3. Applies CSS: it matches style rules to nodes and computes the final style of each one.
  4. Runs JavaScript, which can read and change the tree, react to clicks and make further requests.
  5. Lays out and paints: it calculates where every box goes and draws the pixels — again whenever something changes.

The tree from step 2 is the Document Object Model (DOM): the browser's live, in-memory representation of the page, with an interface through which scripts read and change it. The HTML file is only the starting point. When a script adds a row to a table, the DOM changes and the screen follows, while the HTML source stays the same. That is why "View page source" and the Elements panel of the developer tools can show different things.

Rendering engines

The part of the browser that turns HTML and CSS into pixels is the rendering engine (also called the browser engine). Only three major ones remain:

Engine Used by JavaScript engine
Blink Chrome, Edge, Opera, Brave, Samsung Internet V8
Gecko Firefox SpiderMonkey
WebKit Safari; on iOS, other browsers have historically had to use it as well JavaScriptCore

According to StatCounter, in September 2026 Chrome alone had around two thirds of browser usage across all devices, and Blink-based browsers together about three quarters, with Safari the clear second. For troubleshooting this means: "it works in Chrome but not in Safari" usually points to an engine difference, while "it works in Chrome but not in Edge" (both Blink) points to settings, extensions, policies or cached data instead.

Web origins

Browsers draw their security boundaries around origins. A web origin is the combination of three parts of a URL: the scheme, the host and the port (RFC 6454, now defined in the HTML Standard). The path, the query string and the fragment do not count. When the port is not written, the default for the scheme applies: 80 for http, 443 for https.

Compared with https://portal.example.com Same origin? Why
https://portal.example.com/api/usage Yes Only the path differs
http://portal.example.com No Different scheme
https://api.example.com No Different host — subdomains are separate origins
https://portal.example.com:8443 No Different port

Comparing origins in Python

urllib.parse.urlsplit() splits a URL into its parts; the origin is the scheme, the lowercase host name and the port, filled in with the default when it is missing:

from urllib.parse import urlsplit

DEFAULT_PORTS = {"http": 80, "https": 443}


def origin(url):
    """Scheme, host and port of a URL; the path does not matter."""
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    port = parts.port or DEFAULT_PORTS[scheme]
    return (scheme, parts.hostname, port)  # hostname is already lowercase


page = "https://portal.example.com/account/bills"
others = [
    "https://portal.example.com/api/usage?month=9",
    "https://PORTAL.example.com:443/help",
    "http://portal.example.com/account/bills",
    "https://api.example.com/v1/outages",
    "https://portal.example.com:8443/admin",
]

print("Page origin:", origin(page))
for url in others:
    verdict = "same origin" if origin(url) == origin(page) else "cross-origin"
    print(f"{verdict:13} {url}")
Page origin: ('https', 'portal.example.com', 443)
same origin   https://portal.example.com/api/usage?month=9
same origin   https://PORTAL.example.com:443/help
cross-origin  http://portal.example.com/account/bills
cross-origin  https://api.example.com/v1/outages
cross-origin  https://portal.example.com:8443/admin

The second URL looks different but is the same origin: host names are case-insensitive and 443 is the default port for https. Browsers serialize an origin as text without a path, such as https://portal.example.com — that is the form you will see in the Origin request header below.

The same-origin policy

You are signed in to the customer portal in one tab and open a random website in another. Your browser holds the portal's session cookie. If that website's JavaScript could call https://portal.example.com/account and read the answer, it could read your account details — the browser would even attach your cookie. The same-origin policy prevents this: a script may freely read data from its own origin, but not responses from, or the documents of, another origin.

Across origins, a page… Examples
may embed resources Images, stylesheets, scripts, fonts, video, frames from other origins are loaded and shown
may send requests Links, form submissions, redirects — and fetch() calls are sent too
may not read the result The body and most headers of a cross-origin fetch() response; the DOM of a frame or window from another origin

The middle row matters for security: the policy protects reading, not sending. A malicious page can still make your browser submit a form to another site, with your cookies — which is why cross-site request forgery (CSRF) exists and needs its own defenses. Cookies follow their own, older rules based on domain rather than origin; see Cookies, Sessions and State.

Cross-origin resource sharing (CORS)

Modern sites often need cross-origin reads on purpose: the portal at https://portal.example.com calls the outage API at https://api.example.com. Cross-origin resource sharing (CORS), defined in the WHATWG Fetch Standard, lets the server declare which other origins may read its responses. The browser adds an Origin header to cross-origin requests; the server answers with Access-Control-* headers; the browser compares the two and either hands the response to the script or blocks it.

Simple requests and preflight requests

Some cross-origin requests could already be made before CORS existed, with an ordinary HTML form. For those "simple" requests the browser sends the request directly and checks the response headers afterward. A request is simple when all of these hold:

  • the method is GET, HEAD or POST;
  • it sets only safelisted headers (such as Accept, Accept-Language, Content-Language, Content-Type);
  • a Content-Type, if present, is application/x-www-form-urlencoded, multipart/form-data or text/plain.

Anything else — a PUT or DELETE, an Authorization header, a JSON body with Content-Type: application/json — could do things a form never could. Before sending such a request the browser asks permission with a preflight request: an OPTIONS request to the same URL that carries the page's Origin, the intended method in Access-Control-Request-Method and the non-safelisted header names in Access-Control-Request-Headers. Only if the answer allows all of them does the real request follow.

CORS preflight sequence. The browser, showing a page from portal.example.com, sends OPTIONS /v1/outages/481 with Origin, Access-Control-Request-Method PUT and Access-Control-Request-Headers authorization, content-type to api.example.com. The API answers 204 with Access-Control-Allow-Origin, -Allow-Methods, -Allow-Headers and -Max-Age. The browser checks the answer, then sends the real PUT request with Origin. The API answers 200 with Access-Control-Allow-Origin, and the script may read the response. Browser page: portal.example.com api.example.com OPTIONS /v1/outages/481 Origin: https://portal.example.com Access-Control-Request-Method: PUT 204 No Content Access-Control-Allow-Origin: https://portal.example.com Access-Control-Allow-Methods: GET, PUT check: origin and method allowed? PUT /v1/outages/481 Origin: https://portal.example.com 200 OK Access-Control-Allow-Origin: https://portal.example.com script may read the response
A preflight: the browser asks with OPTIONS, the server answers with what it allows, and only then is the real request sent. Some headers are left out of the drawing.

The response headers

Header Meaning
Access-Control-Allow-Origin The one origin that may read the response, or * for any origin (only for requests without credentials)
Access-Control-Allow-Methods Preflight answer: methods allowed for the real request
Access-Control-Allow-Headers Preflight answer: request headers the real request may carry
Access-Control-Allow-Credentials true if the browser may send cookies or HTTP authentication and expose the response; then * is not allowed and the exact origin must be named
Access-Control-Max-Age How many seconds the browser may cache this preflight answer
Access-Control-Expose-Headers Response headers that scripts may read beyond the basic safelisted ones

Access-Control-Allow-Origin holds a single origin, not a list. A server that serves several front ends checks the incoming Origin against its own allowlist and echoes it back when it matches — and then sends Vary: Origin, so that caches do not hand one origin's answer to another (see the Vary header).

A preflight and a GET, answered by a local server

This script starts a tiny outage API on the local machine (Python's http.server — fine for learning, not meant for production) that allows one origin. It then plays the browser: it sends a preflight, the real request, and a request from an origin that is not on the list, and prints the status and the CORS headers of each response.

import http.client
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ALLOWED_ORIGINS = {"https://portal.example.com"}


class OutageAPI(BaseHTTPRequestHandler):
    def cors_headers(self):
        origin = self.headers.get("Origin")
        if origin in ALLOWED_ORIGINS:
            self.send_header("Access-Control-Allow-Origin", origin)
        self.send_header("Vary", "Origin")

    def do_OPTIONS(self):  # the preflight
        self.send_response(204)
        self.cors_headers()
        if self.headers.get("Origin") in ALLOWED_ORIGINS:
            self.send_header("Access-Control-Allow-Methods", "GET, POST")
            self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
            self.send_header("Access-Control-Max-Age", "600")
        self.end_headers()

    def do_GET(self):
        body = json.dumps({"region": "North", "outages": 2}).encode()
        self.send_response(200)
        self.cors_headers()
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):  # keep the output clean
        pass


server = ThreadingHTTPServer(("127.0.0.1", 0), OutageAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()


def show(method, origin, extra=None):
    conn = http.client.HTTPConnection("127.0.0.1", server.server_port)
    headers = {"Origin": origin, **(extra or {})}
    conn.request(method, "/v1/outages", headers=headers)
    resp = conn.getresponse()
    print(f"{method} from {origin}")
    print(f"  {resp.status} {resp.reason}")
    for name, value in sorted(resp.getheaders()):
        if name.startswith("Access-Control") or name == "Vary":
            print(f"  {name}: {value}")
    body = resp.read()
    if body:
        print(f"  body: {body.decode()}")
    conn.close()


# 1. The browser asks first: may this origin send GET with an Authorization header?
show("OPTIONS", "https://portal.example.com", {
    "Access-Control-Request-Method": "GET",
    "Access-Control-Request-Headers": "authorization",
})
# 2. The real request from the allowed origin
show("GET", "https://portal.example.com", {"Authorization": "Bearer demo-token"})
# 3. A simple GET (no preflight needed) from an origin that is not on the list
show("GET", "https://other.example")

server.shutdown()
OPTIONS from https://portal.example.com
  204 No Content
  Access-Control-Allow-Headers: Authorization, Content-Type
  Access-Control-Allow-Methods: GET, POST
  Access-Control-Allow-Origin: https://portal.example.com
  Access-Control-Max-Age: 600
  Vary: Origin
GET from https://portal.example.com
  200 OK
  Access-Control-Allow-Origin: https://portal.example.com
  Vary: Origin
  body: {"region": "North", "outages": 2}
GET from https://other.example
  200 OK
  Vary: Origin
  body: {"region": "North", "outages": 2}

Look at the third response: the server answered 200 OK with the full body. Without an Access-Control-Allow-Origin header, a browser would refuse to give that body to the script on other.example — but the request was sent and processed. CORS controls who may read; it is not access control. Protect the API itself with authentication and authorization (see API Authentication).

The classic "CORS error"

A front-end developer reports: "the API is broken, I get a CORS error". The browser console shows something like this:

Access to fetch at 'https://api.example.com/v1/outages' from origin
'https://portal.example.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The key insight: the browser did its job. A CORS error means the server's response did not contain the permission the browser needed. Nothing in the front-end code can grant that permission; the fix is on the server (or the proxy in front of it). Work through it like this:

  1. Read the whole message. It says whether the preflight or the real request failed, and which header was missing or wrong.
  2. Open the Network panel and find the OPTIONS request. Did it get a 2xx status with the right headers? A 404 or 405 means the server or route does not handle OPTIONS. A 401 usually means authentication middleware runs before the CORS handling — browsers never send cookies or Authorization on a preflight, so it must be answered without them.
  3. Check the real response's status. Many frameworks add CORS headers only to successful responses. A 500 or 401 without the header then shows up as a "CORS error" that hides the actual problem: look at the API's logs.
  4. Compare the origins exactly. http versus https, a missing or extra port (localhost:3000 versus localhost:5173), or a trailing slash in the configured value (https://portal.example.com/ is not an origin) all make the match fail.
  5. Credentials: with cookies or credentials: "include", the server must send the exact origin (not *) and Access-Control-Allow-Credentials: true.
  6. Duplicate headers: if both the reverse proxy and the application add Access-Control-Allow-Origin, the browser sees two values and rejects the response. Configure CORS in one place.

Content Security Policy

The same-origin policy keeps other sites out. It does nothing when an attacker manages to inject a script into your page — cross-site scripting (XSS): that script runs with your origin's full rights. Content Security Policy (CSP) is a response header in which a site lists where its page may load scripts, styles, images, frames and connections from. The browser refuses everything else, so an injected script — or a script from an unexpected domain — does not run.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64Value9xQ2w=='; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
  • default-src 'self': by default, load resources only from the page's own origin.
  • script-src 'self' 'nonce-…': scripts from the own origin, plus inline scripts that carry this random, per-response nonce (at least 128 bits). Injected inline scripts do not know the nonce and are blocked.
  • object-src 'none' and base-uri 'self': close two classic side doors (plugins and a rewritten base URL).
  • frame-ancestors 'none': no other site may show this page in a frame, which stops clickjacking.

CSP is defense in depth: the real fix for XSS is output encoding (see the fix: output encoding), and CSP limits the damage when that fails somewhere. Roll it out with Content-Security-Policy-Report-Only first, which reports violations without blocking. The current specification, CSP Level 3, is a W3C Working Draft (as of September 2026); browsers support most of it. CSP is one of several protective response headers described in security headers.

Browser developer tools

Every desktop browser has built-in browser developer tools (DevTools): panels that show what the browser actually requested, received, ran and stored for the current page. Open them with F12 or Ctrl+Shift+I (Cmd+Option+I on a Mac). For anyone who supports web applications they are the first tool to reach for, before any server log.

Panel What it shows Use it for
Network Every request: method, URL, status, request and response headers, body, size, timing, whether it came from cache Failing API calls, redirects, slow pages, preflights, caching. "Preserve log" keeps entries across page loads; "Disable cache" bypasses the browser cache while DevTools is open; "Copy as cURL" turns a request into a command you can rerun
Console JavaScript errors and the browser's own warnings CORS, mixed content and CSP violations, script errors
Elements (Inspector in Firefox) The live DOM and the CSS applied to each element "The button is there but invisible", layout problems
Application (Storage in Firefox) Cookies, local and session storage, cache storage, service workers Session problems, stale data, clearing one site's data