Recently, to let a hosted web app talk to servers we control on other hosts, the project I work on introduced an allowed-origins list. The discussion around it was enlightening in the way that only production incidents are. It still surprises me how misunderstood CORS protections are among engineers who do not work directly with core web or browser technologies. But it is also understandable. Most of the time it is set-and-forget. The browser handles it for you, and the whole mechanism hides behind a safe-feeling checkbox.

1. What CORS actually is

Cross-Origin Resource Sharing is an HTTP-header mechanism that lets a server tell the browser which origins are allowed to read a response. The canonical definition is from MDN:

CORS is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources.

That definition quietly contradicts a few assumptions people bring to CORS. The ones that bite hardest:

  1. CORS is not server-side access control. The request still reaches your server. An attacker's page can POST to your endpoint all day; the browser only refuses to expose the response to the calling script. PortSwigger puts it bluntly in their CORS research: "CORS is not a protection against cross-origin attacks such as cross-site request forgery (CSRF)". The Express cors package README says the same thing in plainer English: any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings. Authentication and authorization remain your job, server-side, unconditionally.

  2. CORS is not applied to non-browser clients. curl, CLIs, SDKs, server-to-server calls: none of them send an Origin header, and none of them enforce CORS. If your security story depends on the browser "blocking" bad actors, you have no story at all for scripts.

  3. CORS is not implemented by your server. The browser is the enforcer. Your server emits headers; the browser decides what to expose. That single fact explains most "works in curl, broken in the browser" mysteries.

Browser and non-browser traffic are two different trust paths

In the incident that started this, the exact failure mode was: curl /api/tags worked, and every browser streaming session failed with a rejected-origin error. Users concluded the change was "applied unequally" because they did not know that browsers send Origin and scripts do not. In practice: non-browser paths are gated by auth (API keys, tokens), browser paths are gated by origin validation plus auth. Never break the non-browser path while tightening the browser path, and test both before shipping.

2. When preflight fires, and why

The browser sends Origin on cross-origin requests and preflights the ones that can cause side effects. Per MDN, a request triggers an OPTIONS preflight when it is not "simple": methods other than GET, HEAD, or POST; content types other than application/x-www-form-urlencoded, multipart/form-data, and text/plain; or any non-safelisted custom header (an Authorization header is the one that bites most API engineers). application/json POST is not simple, which is why every JSON API bumps into preflight on day one.

Diagram

GET/HEAD/POST, simple content types, no custom headers

PUT/DELETE, application/json, Authorization, custom headers

yes

no

ACAO matches origin

no check / mismatch / * with credentials

Browser makes cross-origin request

Is it a simple request?

Send request directly

Browser attaches Origin header

Server responds with ACAO

Response readable?

Send OPTIONS preflight

Server responds with ACAO / ACA Methods / ACA Headers

Server permits?

Browser blocks, JS gets a CORS error

Response exposed to page

Rules worth memorizing, all from MDN and the Fetch spec:

  • Access-Control-Allow-Origin: * cannot be combined with credentials. MDN: "The server must specify an origin in the value of the Access-Control-Allow-Origin header, instead of specifying the * wildcard" when responding to credentialed requests. The browser will block the response outright.
  • If you reflect a single origin dynamically (allowlist matching), you must send Vary: Origin. Otherwise shared caches (CDNs, proxies) can serve a cached response stamped for one origin to a different one: cache poisoning. MDN: "the server should also include Origin in the Vary response header."
  • Origin can serialize as null. Sandboxed iframes, data: URLs, about:blank, and some redirects send Origin: null. Whitelisting null is one of PortSwigger's canonical CORS misconfigurations: an attacker can trigger it from a sandboxed page. Treat null as untrusted unless you have a concrete reason.
  • Preflight caching (Access-Control-Max-Age) defaults to 5 seconds per MDN; browsers cap it internally. Raising it reduces round trips for chatty clients but lengthens the window in which a config change does not take effect for clients with cached preflights.
  • Middleware ordering matters. If your auth middleware runs before the CORS handler, it can reject the OPTIONS preflight before any CORS headers exist, and the browser masks real 4xx/5xx errors as generic CORS failures when the error response lacks CORS headers. Answer OPTIONS before auth, and emit CORS headers on error paths too.

3. WebSockets do not play by these rules

This is the section most server-side engineers never get taught. RFC 6455 (The WebSocket Protocol, 2011) section 1.6 explains that Origin exists to protect against unauthorized cross-origin use of a WebSocket server by scripts: the server is informed of the origin generating the connection request and may reject it with an HTTP error. For browser clients that is not optional. Section 4.1 step 8 requires the handshake to "include a header field with the name Origin" when the request comes from a browser client, and section 4.2.1 item 7 says a connection attempt lacking the header should not be interpreted as coming from a browser client. Non-browser clients may omit it.

Why does this matter? Because WebSockets are not subject to the Same-Origin Policy. A malicious page can open a WebSocket to any host, cookies go along for the ride, and CORS never intervenes. The only server-side defense is to validate Origin at the handshake. RFC 6455 section 10.2:

Servers that are not intended to process input from any web page but only for certain sites SHOULD verify the Origin field is an origin they expect. If the origin indicated is unacceptable to the server, then it SHOULD respond to the WebSocket handshake with a reply containing HTTP 403 Forbidden status code.

The attack this defends against has a name: Cross-Site WebSocket Hijacking (CSWSH). The OWASP WebSocket Security Cheat Sheet walks the scenario: user logs into your app, session cookie established; user later visits a malicious site; the malicious site opens a WebSocket to your app; the browser sends the cookies automatically; the server accepts and the attacker gets live, authenticated access. Real-world case cited by OWASP: the 2023 Gitpod CSWSH vulnerability, where insufficient origin validation allowed full account takeover.

OWASP's prescribed defense, verbatim:

Validate the Origin header on every handshake. Always use an explicit allowlist of trusted origins. Browsers include this header and malicious JavaScript cannot override it.

Use an allowlist, not a denylist. No wildcards, no substring matching. Compare scheme, host, and port.

Diagram
Your serverBrowser (evil.com)Your serverBrowser (evil.com)connection never establishedfull-duplex channel openalt[Origin not allowed][Origin allowed]HTTP Upgrade: GET /socketOrigin: http://evil.comCookie: session=...Is Origin in allowlist?(scheme + host + port exact)HTTP 403 Forbidden101 Switching Protocols

The OWASP cheat sheet adds the adjacent defenses: prefer wss:// over ws:// in production, use SameSite cookies to blunt cross-site cookie transmission, use CSRF tokens in the handshake for apps that already use CSRF protection, and validate sessions on long-lived connections (re-validate every ~30 minutes; close on expiry).

One nuance worth keeping straight: TLS kills DNS rebinding but not CSWSH. Over wss://, a rebinding attacker's certificate validation fails, so the rebinding vector dies at the handshake. CSWSH does not involve rebinding at all: the attacker connects to your real host, and TLS authenticates the server to the browser, not the requesting origin to the server. That is why origin validation stays mandatory even on wss://.

4. The attacks you are actually defending against

CORS configuration is only sensible once you know the threat model. For a server-side engineer, four attacker stories matter:

Table
AttackWhat happensWho is at riskPrimary defense
CSRFA malicious page triggers a state-changing request via your victim's browser (cookies auto-attached).Any cookie-authed web appCSRF tokens, SameSite cookies, custom-header requirements
Cross-origin data theftMalicious origin reads responses your server is willing to share, via permissive CORS.Cookie-authed apps with misconfigured ACAOExact-origin allowlist, no * with credentials, Vary: Origin
DNS rebindingAttacker's domain resolves to your server's private IP; Origin and Host both read the attacker's domain, so naive "same-origin" checks pass.Local/LAN services on non-loopback bindsHost-header validation against server-known identities, TLS, LNA prompts
CSWSHMalicious page opens a WebSocket to your server, cookies attached, no SOP gate.Any server with WebSocketsOrigin allowlist at handshake (RFC 6455 10.2)

The messy one for local/self-hosted services is DNS rebinding, because the naive fix ("allow requests whose Origin matches Host") is exactly what the attack exploits. After a rebind, Origin: http://evil.com and Host: evil.com match. The robust rule is to validate the Host against identities the server derives from its own OS (interface table, hostname, mDNS), never by resolving the Host header. If you implement "allow any Host that resolves to one of my IPs", you have reimplemented the attack, because the attacker's host does resolve to your IP during the attack.

There is a second layer worth knowing about, because it changes the calculus for local-first apps. The browser platform now ships its own gate for public-to-private traffic. The feature formerly known as Private Network Access (PNA, originally CORS-RFC1918) is shipping as Local Network Access (LNA): as of 2026, in Chromium (Chrome and Edge) it is the permission-prompt model gated behind fetch(url, { targetAddressSpace: "local" }) (Chrome blog, June 2025), and in Firefox it rolled out through 2026 (restrictions on by default for all users from Firefox 153). Safari has not shipped it. Chrome split the permission into local-network and loopback-network in 145, and WebSocket traffic gained LNA coverage in Chromium 147, so exact coverage depends on the version. Spec-wise it remains a WICG proposal (private-network-access) rather than a Fetch-spec section. Treat browser support as uneven, and date-stamp your assumptions.

Defense in depth: why server-side validation still exists

LNA means a public site can no longer silently reach a user's local server in the browsers that support it: the user is prompted. But "prompted" is not "blocked", Safari lags, non-secure contexts are excluded, and WebSocket path behavior has had quirks. Server-side origin validation is the backstop that works everywhere, independent of browser rollout. Ship it. Just do not pretend it is the only layer.

5. The levers you can pull

Here's every lever on the board and what it costs you:

Table
LeverWhat it buysCost / trade-off
Access-Control-Allow-Origin: *Any origin can read responses.Only for no-credential, public-data APIs. Breaks the moment you add credentials; blocks Set-Cookie.
Exact-origin allowlist + Access-Control-Allow-Credentials: trueCredentialed cross-origin reads for a known set of origins.You maintain the list. Requires Vary: Origin. Every new frontend host is a config change and a potential support thread.
Reflected origin (echo Origin back)Zero list maintenance for a dynamic allowlist.Only safe when the echo comes from a server-side allowlist match. Reflecting arbitrary input is the #1 PortSwigger misconfiguration.
Preflight handler (Allow-Methods, Allow-Headers, Max-Age)Precise permission for non-simple requests.Wrong lists produce confusing failures; every header you allow widens what the page can send.
WS handshake Origin validationCloses CSWSH.Mandatory, not optional. Reject before the connection is established, log the origin.
Host-header validation ("self set")DNS-rebinding resistance for LAN/self-hosted services.Requires OS identity enumeration and a refresh policy (see section 7).
Bind address (loopback vs 0.0.0.0)Loopback binding removes most exposure by construction.Kills LAN/remote use cases. Non-loopback binds need API keys and origin rules.
Reverse proxy / TLS terminationTLS kills rebinding; one place to manage headers.Adds a trust boundary: honor X-Forwarded-Proto only from known proxies, or not at all.
LNA permission promptingBrowser-side gate for public-to-local traffic.Not your lever to pull or configure; varies by browser and lags Safari (no support). Legacy PNA preflight also required the server to answer Access-Control-Allow-Private-Network; check current guidance for your browser targets.

The one default I would fight to keep out of the box is the wildcard. * is an escape hatch, not a default. Some local-first servers ship it for "make it work" support; if you support it, warn loudly, document it as discouraged, and keep an exact-origin path documented. In the incident, the * workaround users found on a forum was the answer that kept them stuck on an insecure default.

6. Which levers are worth your time

The question this post is trying to answer: given my app, my audience, my deployment, which levers should I actually pull? Work through these in order. Most of the answers will be "no", and each one keeps you out of a category of support tickets.

Q1. Do browsers talk to your server at all? If your consumers are CLIs, SDKs, server-to-server integrations, or mobile apps: no CORS work is warranted. CORS is a browser response-sharing protocol. Adding headers anyway does not make your API "more open", it makes it less safe by widening browser-side exposure for no benefit.

Q2. Cookies or tokens? Cookie-based auth is where CORS gets expensive: credentialed requests need exact-origin ACAO (never *), Access-Control-Allow-Credentials: true, Vary: Origin, plus CSRF story (see the OWASP CSRF Prevention Cheat Sheet). Token-based auth (Authorization header) still triggers preflight for the custom header, but the credentials rules vanish and CSRF mostly does too. If you can move to tokens, you spend less time in CORS land.

One LAN-specific trap: cross-site credentialed cookie flows require SameSite=None; Secure, and Secure cookies cannot be set over plain http:// (localhost excepted). So on http://192.168.x.x, cookie-authed cross-origin calls effectively cannot work at all. If your LAN-facing deployment is plain HTTP, budget for token auth or TLS; do not plan on cookies.

Q3. Who can reach the port?

  • Loopback only: the browser gate (loopback exemptions, LNA) already protects you; keep origin handling minimal (allow loopback, allow desktop webview schemes).
  • LAN (your own hosts, mDNS, VPN): build the zero-config same-origin rule with Host validation (section 7). No config burden on users, rebinding still rejected.
  • Public / remote names (DynDNS, Tailscale, tunnels, hosted frontends): explicit allowlist, authoritative when set, plus TLS.

Q4. Do you serve WebSockets? Then handshake Origin validation is mandatory, full stop. This is non-negotiable per RFC 6455 and OWASP; it is the cheapest CSWSH defense that exists.

Q5. What does a misconfiguration cost you? This is the ignored question in most CORS tutorials. For a public SaaS, a permissive * on an authenticated endpoint is a data-exfiltration vulnerability: that is an incident. For a home-lab dashboard behind a LAN, the worst realistic outcome of an over-permissive origin is usually an annoying support thread, but the worst outcome of a misjudged hardening is a broken dashboard for every user, which is exactly what happened in our case. Decide which failure you can't live with before you pick a default.

The CORS decision framework: four sequential questions, each ending in a ship-early exit or a continue
The decision framework — walk Q1→Q4 in order; the first terminal answer ends the work.

7. The zero-config LAN pattern

The pattern that ended the incident is worth stealing for any local-first server: allow same-origin requests when the request's Host is one of the server's own identities, derived from the OS, never from DNS. Browsers make this free: when a user opens http://192.168.1.17:8080, the browser sends Origin: http://192.168.1.17:8080 and Host: 192.168.1.17:8080. Origin is Host by construction. The server only needs to recognize itself, safely. The only wrinkle: Origin always carries its scheme, while Host does not, so the comparison must normalize both sides before it can ever match.

PYTHON
def is_origin_allowed(origin, host, scheme, allowlist):
    if not origin:                            # curl, CLIs, SDKs
        return True                           # gate by auth, not by Origin
    if allowlist:
        return origin in allowlist            # explicit intent is authoritative;
                                              # note: a custom allowlist disables the
                                              # zero-config fallbacks below by design

    # Normalize both sides to scheme + host:port before comparing
    req_origin = parse_origin(origin)         # e.g. "http://192.168.1.17:8080"
    expected_origin = f"{scheme}://{host}"

    if is_loopback(host):
        return req_origin == expected_origin  # Also matches registered local webview schemes (app://, file://, etc.)
    if host in server_self_set():             # OS interfaces, hostname, mDNS
        return req_origin == expected_origin
    return False                              # reject; log origin + remediation hint
Origin-validation precedence: explicit allowlist, then loopback and desktop schemes, then same-origin with a known Host, then reject and log
Precedence order for origin validation — each tier is a fallback for the one above it.
The loopback branch is where people get burned

A naive if host is loopback: return true allows any origin to read the server's responses: a malicious page at evil.com can fetch("http://localhost:8080/...") and, if the server echoes Access-Control-Allow-Origin: evil.com, read localhost data. That is the same cross-origin data theft described in section 4, served over loopback. Real local servers usually get away with it because desktop webviews and localhost pages are the only legitimate users and because modern browsers gate public-to-loopback traffic with LNA prompts, but Safari and older Firefox do not. The safe rule is to require origin == host on the loopback branch too (a localhost page sending Origin: http://localhost:8080 passes; evil.com does not), and to treat native desktop webview schemes (file://, app://, jan://) as their own explicit allowlist rather than a blanket loopback bypass. Regression test for this exact hole: is_origin_allowed("http://evil.com", "localhost:8080", ...) == false.

Setting any custom allowlist disables the zero-config fallbacks

If a user sets allowed_origins = ["https://app.example.com"], every LAN request via http://192.168.1.17:8080 starts being rejected unless the user also lists their own LAN IP. That is correct by design — explicit config is authoritative — but it routinely surprises self-hosters, so call it out in docs: custom allowlists disable the zero-config fallbacks by design. If the goal is "allow my own LAN plus a hosted frontend", the LAN entries must be added to the allowlist explicitly.

Where server_self_set() is:

  • every local interface address (IPv4 and IPv6) from the OS interface table (getifaddrs / GetAdaptersAddresses), not just the bind address, so Wi-Fi, Ethernet, VPN and vLAN interfaces all work;
  • the machine hostname and hostname.local (mDNS names are link-local by definition and cannot be forged through public DNS);
  • the configured bind host when non-wildcard.

Anything not in that set falls through to the explicit allowlist: DynDNS names, Tailscale MagicDNS, custom reverse-proxy hostnames, and so on. The same check runs on the WebSocket Upgrade handshake.

The precedence contract (the part reviewers will fight about)

The evaluation order must be pinned in the design doc: explicit allowlist is authoritative when set, then loopback/desktop schemes, then same-origin-with-server-known-Host, then reject-with-log. The tempting alternative, checking same-origin before the explicit list, silently makes the user's explicit config meaningless every time the two disagree. Pin it, say it out loud in review, and add the regression test: is_origin_allowed("http://evil.com", "evil.com:8080", {self}) == false. The flip side of that precedence is the warning above: because the allowlist wins unconditionally, anyone who sets one must include every origin they still want to reach — zero-config coverage does not survive a custom allowlist.

A few implementation details are easy to get wrong:

  • Empty Origin must stay allowed. The code path that extracts Origin must not turn absence into rejection, or you have re-broken every CLI and SDK user. Test it explicitly.
  • Refresh the self set. Compute it at startup and re-enumerate on config change and on a short TTL (60 seconds is a sane default), or on interface-up events where the platform exposes them. A user who connects a VPN after the server started should see the new interface appear without a restart. Serve an immutable snapshot to worker threads instead of a shared mutable set.
  • Normalization deserves unit tests. Default ports (:80/:443 omitted), hostname case, trailing dots (mybox.local.), IPv6 zone IDs, IPv4-mapped IPv6, and the wss/ws vs https/http scheme mapping all need explicit tests. This is where "it worked on my machine" bugs are born. The pseudocode above collapses the whole comparison to req_origin == expected_origin precisely so that every one of these cases has exactly one place to be tested and one place to be wrong.
  • Proxy boundary. If a reverse proxy or tailscale serve terminates TLS, the upstream server sees plain HTTP and must recover the original scheme from X-Forwarded-Proto. Honor that header only from known, configured proxies, or do not read it at all. A client spoofing X-Forwarded-Proto: https over plain HTTP changes the scheme inside your origin comparison, which is a trust boundary you do not want to hand to arbitrary clients.

8. Rollout discipline

The post-mortem of the incident was not "we had the wrong origin check." It was "we shipped a breaking change without flagging it, without a migration path, and without a discoverable way out." The security change was intentional, correct, and released as an unannounced breaking change anyway. That is a process failure, not a CORS failure, and it is the most transferable lesson in this post.

When you change origin behavior, ship in stages. In practice the first two often land in the same PR; the order is what matters, not the ceremony:

  1. Diagnosis release: add actionable logging. When a request is rejected, log the rejected origin plus a remediation hint. That single change turns support threads into self-service and tells you how many real users are affected before you break anything.
  2. Config release: land the allowlist surface (config file, CLI, admin API), apply it live without a restart, and make explicit configuration authoritative. Give the conscientious users a knob before you take away the permissive default.
  3. Restore-UX release: add the zero-config same-origin rule (the self set) so legitimate LAN users get their pre-hardening experience back, with the security backstop already in place.
  4. Only then, harden the default. With docs, migration guides, GUI knobs, and loud warnings on non-loopback binds already shipped. Hardening should be a visible, reversible settings change, not a surprise.

Ground rules that apply across all four:

  • Never ship a relaxation without its backstop.
  • Keep knob semantics stable across releases. Do not rename allowed_origins, do not flip precedence mid-cycle, do not change what "same-origin" means between versions. Change defaults and validation only, each with a loud notice.
  • Test the browser path and the CLI path. Test the "user who only set host=0.0.0.0" case. Test "VPN connected after start." Test the rebinding regression (origin == host == evil.com). If those four tests are in CI, the worst incidents in this space stop being possible.

9. TL;DR

  • CORS is a browser opt-in for reading responses. It is not access control, it does not apply to scripts, and it is enforced by the browser, not your server.
  • * + credentials never works. Reflect origins only from an allowlist, and send Vary: Origin when you do.
  • WebSockets bypass SOP: validate Origin at the handshake, per RFC 6455 and OWASP, or you have CSWSH.
  • DNS rebinding defeats naive Origin == Host checks; validate Host against OS-derived identities, never by resolving it.
  • The zero-config LAN pattern (same-origin + self set) is the sweet spot for local-first servers. Explicit allowlist wins whenever it is set — and by winning, it disables the zero-config fallbacks, so a custom allowlist must list every origin you still want to reach.
  • Browsers are slowly becoming a gate themselves (LNA in Chrome/Edge/Firefox; Safari lags). Date-stamp your assumptions and keep server-side validation as the backstop.
  • For a public SaaS, a loose * is a breach; for a LAN tool, a misjudged hardening is an outage. Decide per context, and roll out origin changes like the breaking changes they are.