SecurityGENERALBROWSER-SPECIFICNETWORK-SPECIFIC

CORS

A browser policy about whether script may read a cross-origin response. Not authentication, not a firewall, and not relevant to anything that is not a browser — plus the error message that lies to you.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What is CORS actually deciding, and why does my console blame it for a server error?

The user intent

A developer wants their page to call an API on another host. A user wants the page to work.

The obvious build

The request is being blocked, so there must be a header or a flag that unblocks it. Set Access-Control-Allow-Origin: * and move on.

Why it breaks

The wildcard is rejected the moment credentials are involved, so the configuration that unblocks development is exactly the one that fails once cookies are attached.

How it breaks in a real browser
  • The wildcard is rejected the moment credentials are involved, so the configuration that unblocks development is exactly the one that fails once cookies are attached.
  • The wildcard is also a decision to let every origin on the internet read those responses in a browser — which is fine for a public asset and is not fine for anything user-specific.
  • Roughly half of what people call CORS errors are not CORS problems: a 500, a connection reset, a redirect to a login page, or a DNS failure all produce a message naming CORS, because a failed response carries no CORS headers (Debugging the Network).
  • A dev proxy that makes everything same-origin does not model the boundary; it hides it, so the first real encounter is in staging with a deadline attached (A Method for Frontend Bugs).
  • Preflights are a round trip you added, and on a high-latency network they are visible in the waterfall. A custom header added for tracing can double the latency of a hot endpoint (Reading a Network Waterfall).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • CORS answers exactly one question: may script on origin A read this response from origin B? The response is a piece of data, and the mechanism governs whether the browser hands it to your code (The Same-Origin Policy).
  • The responding server answers by sending headers. Access-Control-Allow-Origin names an origin or *; Access-Control-Allow-Credentials, -Allow-Methods, -Allow-Headers and -Expose-Headers fill in the rest.
  • A simple request is sent straight away and the check happens on the response. It qualifies if the method is GET, HEAD or POST, the headers are within a small safelisted set, and the content type is application/x-www-form-urlencoded, multipart/form-data or text/plain.
  • Anything else triggers a preflight: the browser first sends an OPTIONS request carrying Access-Control-Request-Method and -Request-Headers, and only sends the real request if the answer permits it. Content-Type: application/json alone is enough to trigger this, which is why nearly every JSON API sees preflights.
  • Credentials are opt-in on both sides. The request must ask (credentials: "include", or withCredentials), and the response must carry Access-Control-Allow-Credentials: true *and* an explicit origin — the wildcard is rejected in that combination by design, because "any origin may read this user-specific response" is not a thing the specification will let you say by accident.
  • The browser also hides response headers by default. Your code sees a small safelisted set unless the server lists more in Access-Control-Expose-Headers, which is why a pagination or request-id header can be present on the wire and absent in your client (Request IDs: The Contract's Correlation Clause).
  • Access-Control-Max-Age lets the browser cache a preflight result per origin, method and header set. Caps differ by browser, so it is a reduction rather than an elimination.
  • All of this is enforced by the browser, on the client, after the fact. The request generally reached the server and was generally processed; what the browser withheld is your access to the answer.

What this makes the browser do

And which of it is avoidable.

  • An extra OPTIONS round trip for every non-simple request whose preflight is not cached — the single largest cost of this mechanism, and the one that shows up as latency rather than as an error.
  • Header matching per response, plus filtering the response header list down to what the server exposed before your code ever sees it.
  • For no-cors requests, fetching the resource and then handing script an opaque response — you pay the full network cost and receive nothing readable (The Life of a Fetch).
  • Redirect handling on preflighted requests, which the specification restricts: a preflighted request that redirects has historically failed in ways that are hard to read from the console.
  • Avoidable work: a custom header added to every request by a client wrapper that turns every simple GET into a preflighted one, doubling round trips across the whole application.

What actually happens on the wire

The sequence is short and worth knowing exactly, because every confusing case is a step in it behaving as specified. Note where the decision happens: at the end, in the browser, after the server has already done the work.

The consequence of that ordering is the thing to carry away. A request that "was blocked by CORS" was, in the overwhelming majority of cases, sent, received, processed and answered. Nothing about this prevented the server from acting.

A cross-origin `fetch`, step by step
  1. 1
    Classify

    The browser decides whether the request is simple — method, headers and content type all within the safelists — or requires a preflight.

    fails by Silently: adding one custom header moves an endpoint from one class to the other with no visible change in your code.

  2. 2
    Preflight (if needed)

    Sends OPTIONS with Access-Control-Request-Method and -Request-Headers; the server answers with what it permits.

    fails by A gateway or framework answering OPTIONS before your application sees it, and answering it wrongly (The Gateway as Policy Boundary).

  3. 3
    Send the real request

    Sends the actual method and body, attaching cookies only if the request asked for credentials.

    fails by Credentials silently absent because the request never opted in, producing a 401 that reads as an authentication bug.

  4. 4
    Server handles it

    The application processes the request and produces a response. It has already changed state by this point if it was going to.

    fails by Returning an error — and an error response usually carries no CORS headers, which is where the misleading message comes from.

  5. 5
    Check the response

    The browser compares Access-Control-Allow-Origin (and the credentials header, if credentials were sent) against the requesting origin.

    fails by A wildcard with credentials, or an origin that does not match exactly — subdomain, scheme and port all count.

  6. 6
    Expose or withhold

    On success, hands your code the response with a filtered header list. On failure, rejects the promise with no status and no body.

    fails by A header your client depends on being absent because it was never listed in Access-Control-Expose-Headers (Request IDs: The Contract's Correlation Clause).

Steps 3 and 4 are the ones people forget. The server did the work; the browser withheld the answer from your script.

What a preflight costs

Preflights are usually discussed as a correctness topic and experienced as a latency one. The shape below is what an uncached preflight does to a request path: it is not extra bytes, it is an extra serialised round trip before the request that matters can even start.

The practical read: a JSON API is preflighted by default, and the mitigation is caching the preflight rather than avoiding it. Making a request simple enough to skip preflight usually means giving up a content type worth having.

Same request, preflight cached and not cachedrelative units — a schematic shape, not a measurement
UNCACHED — OPTIONS out
UNCACHED — OPTIONS response
UNCACHED — POST out
UNCACHED — server work
UNCACHED — response read
CACHED — POST out
CACHED — server work
CACHED — response read
  • UNCACHED — OPTIONS outThe real request has not started. Nothing useful is happening on the server yet.
  • UNCACHED — POST outOnly now does the request the user is waiting for begin.
  • CACHED — POST outPreflight result still valid for this origin, method and header set.
  • CACHED — server workIdentical work. The server never noticed the difference.

The gap is one round trip, so it scales with network latency and not with payload size — which is exactly why it is invisible locally and obvious on a mobile connection.

Reading the error correctly

The console message is the most misleading diagnostic in frontend work, because it names the mechanism that reported the problem rather than the one that caused it. Work the table below top to bottom before you change a header, and most of these resolve without touching CORS configuration at all.

The message says CORS. What is it really?
TriggerSymptomCauseResponse
Server returned 5xx"No Access-Control-Allow-Origin header is present"Error responses are usually generated by a layer that does not add CORS headersRead the server log. Fix the 500. The header was never the problem (What a Backend Should Actually Log).
Session expiredThe same call worked a minute ago and now fails as a CORS errorA cross-origin redirect to a login page, and the redirect carries no CORS headersReturn 401 with CORS headers instead of redirecting an API client (Session Expiry and the Refresh Race).
A header was added to the clientA previously fine GET now fails on OPTIONSThe request became non-simple and the server does not allow that headerAdd it to Access-Control-Allow-Headers, or reconsider whether the header is worth a round trip.
Credentials turned on"The value of Access-Control-Allow-Origin must not be the wildcard"Wildcard plus credentials is rejected by the specificationEcho a specific origin from a server-side allowlist. Never reflect Origin unchecked.
A response header is missing in coderes.headers.get(...) returns null but the header is visible in devtoolsResponse headers are filtered before script sees themList it in Access-Control-Expose-Headers (Network Failures Only the Client Can See).
Works in curl, fails in the browserTwo clients, two outcomes, same endpointThis is a browser-side policy; curl is not a browser and never evaluates itConfirms the diagnosis rather than contradicting it — and shows why a server-side check is still required (What the Frontend Is Responsible For in Auth).
The credentialed case, in full
1# 1. Preflight — triggered by Content-Type: application/json
2OPTIONS /v1/orders HTTP/1.1
3Host: api.example.com
4Origin: https://app.example.com
5Access-Control-Request-Method: POST
6Access-Control-Request-Headers: content-type
7
8HTTP/1.1 204 No Content
9Access-Control-Allow-Origin: https://app.example.com # exact, never *
10Access-Control-Allow-Methods: POST, GET
11Access-Control-Allow-Headers: content-type
12Access-Control-Allow-Credentials: true
13Access-Control-Max-Age: 600 # browsers clamp this
14Vary: Origin # or a CDN will mix them up
15
16# 2. The real request
17POST /v1/orders HTTP/1.1
18Origin: https://app.example.com
19Content-Type: application/json
20Cookie: session=... # only because the client asked for credentials
21
22HTTP/1.1 201 Created
23Access-Control-Allow-Origin: https://app.example.com
24Access-Control-Allow-Credentials: true
25Access-Control-Expose-Headers: x-request-id # or script cannot read it
26X-Request-Id: 6f2c...

Vary: Origin is the line most often missing. Without it, a shared cache can hand one origin's allow header to a different origin, and the failure comes and goes with cache state.

How to build it

Most important first.

  • Prefer same-origin. Routing the API under the app's origin at the edge removes preflights, removes the header configuration and removes most of the cookie conversation in one decision (Origins and the Sandbox).
  • When you do need cross-origin, name the origins explicitly server-side and treat the list as reviewed configuration rather than a wildcard someone typed during an incident (The Trust Boundary).
  • Keep requests simple where you can. A GET with no custom headers is not preflighted; adding one header to it is a latency decision as much as a functional one.
  • Set Access-Control-Max-Age deliberately so repeat preflights are cached, and remember it is capped differently per browser.
  • Expose the response headers your client actually needs — request id, pagination, rate-limit — rather than discovering later that they were stripped (Network Failures Only the Client Can See).
  • Use a development proxy that preserves origin semantics, or better, replicate the production origin topology locally so the boundary exists in development too.
  • Never demonstrate or fix anything with a browser security flag disabled. The result is code validated against a browser that no user runs.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A cross-origin failure that leaves a region empty is invisible to anyone not looking at that part of the screen. Render a real error state and announce it (Live Regions and Announcement).
  • Fonts loaded cross-origin require crossorigin on the preload and a permissive header; when that is wrong the font silently falls back, which can change line length, wrapping and legibility for users who need a specific typeface (Images and Fonts).
  • A failed API call that leaves a form in a pending state traps a keyboard user in a disabled control with no announced reason. Restore interactivity and move focus to the error (Errors People Can Actually Perceive).
  • Retry affordances after a network-shaped failure must be reachable by keyboard and labelled meaningfully — "Retry loading orders", not an icon button named "Retry" with no context (Keyboard Operability).

What can go wrong

Failure modes
  • The diagnosis trap: the server returned 500, so it sent no CORS headers, so the browser reports a CORS failure and your code never sees the status. Check the server's own logs before touching a single header.
  • A redirect to a login page from a cross-origin API — the redirect response has no CORS headers, so an expired session presents as a CORS error rather than a 401 (Session Expiry and the Refresh Race).
  • Wildcard plus credentials, rejected by the browser. The fix is to echo the specific origin from an allowlist, never to reflect whatever arrived in the Origin header.
  • Reflecting the Origin header unconditionally, which is a wildcard that also works with credentials — the most dangerous misconfiguration in this area and the one that looks most like a fix.
  • A preflight that never reaches your application because a gateway, a load balancer or a framework handles OPTIONS first and answers it wrongly (The Gateway as Policy Boundary).
  • The mitigation failing: an origin allowlist matched by prefix or substring, so https://app.example.com.attacker.example passes.
What can arrive out of order
  • A cached preflight answer can outlive a configuration change, so a tightened allowlist takes effect for different users at different times depending on when their browser last preflighted.
  • A CDN caching a response without Vary: Origin can serve origin A's allowed header to origin B, producing an error that appears and disappears with cache state (Browser HTTP Caching).
  • A session expiring mid-flight turns a normal response into a cross-origin redirect, so the same request succeeds and then fails for reasons that look unrelated to authentication (Session Expiry and the Refresh Race).
Security
  • What this mechanism does: it lets a server declare that a named browser origin may read its responses. That is the whole of it.
  • What it does not do: it performs no authentication and no authorization, it is not a firewall, and it has no effect on a client that is not a browser. A script, a proxy or a mobile app reads whatever the server returns (What the Frontend Is Responsible For in Auth).
  • Loosening it is a real exposure in exactly one direction — a permissive configuration combined with credentials lets a hostile page read authenticated responses on behalf of a signed-in user, which is a data breach rather than an inconvenience.
  • It does not prevent a request from being sent, so it is not a defence against forged state changes; that needs its own mechanism (Cross-Site Request Forgery).
  • Access-Control-Expose-Headers is a small privacy surface too: exposing internal headers gives client-side code, including any injected script, more to work with (Cross-Site Scripting).
Misreads
  • "CORS secures my API." It secures nothing. It is a browser policy governing whether script may read a cross-origin response, and a non-browser client is entirely unaffected by it.
  • "The browser blocked my request." Usually it sent the request, received the response, and withheld it from your code. The server almost certainly did the work.
  • "It is a CORS error, so the fix is a header." Check the status first. A 500, a redirect or a connection failure produces the same message because a failed response has no CORS headers on it.
  • "Access-Control-Allow-Origin: * is a good default." It is correct for genuinely public resources and wrong for anything user-specific, and it stops working entirely once credentials are involved.
  • "Reflecting the Origin header is the flexible version of an allowlist." It is a wildcard that also works with credentials — the most consequential misconfiguration in this area.
  • "CORS stops other sites calling my API." It does not stop the call. Only a server-side check does (Cross-Site Request Forgery).

Measuring it, and what changes in the field

How you would see this
  • The Network panel shows preflights as separate OPTIONS entries. Their presence, count and timing are the first thing to look at when a cross-origin API feels slow (Reading the Browser Waterfall).
  • The console message distinguishes the failure modes precisely — missing header, origin mismatch, wildcard with credentials, method not allowed, header not allowed — and the distinction is the fix.
  • Server access logs settle the diagnosis trap in seconds: if the request is in the log with a 500, the CORS message was a symptom (What a Backend Should Actually Log).
  • A curl or a request from a non-browser client will succeed against an endpoint your page cannot read, which is the fastest demonstration that this is a browser-side policy and not a server-side gate.
Slow device, slow network, large data, old tab
  • On a high-latency network the preflight round trip is felt directly; on localhost it is nearly free, which is why this is discovered in production (Bandwidth vs Latency).
  • With HTTP/2 or HTTP/3 the connection is already open and multiplexed, so a preflight costs a round trip rather than a new connection — cheaper, and still a round trip (HTTP/1.1 vs HTTP/2 vs HTTP/3).
  • Behind a CDN, Vary: Origin matters: without it a cached response carrying one origin's header can be served to another origin and fail confusingly (CDN Delivery).
  • Under a service worker, requests are intercepted before this evaluation, so a worker that constructs responses can mask or change the behaviour you are debugging (Intercepting Fetch).
What this costs
  • Same-origin removes the whole mechanism and couples app and API deployment behind shared routing infrastructure (How API Shape Drives UI Complexity).
  • A permissive configuration is fast to ship and is a standing decision about who may read your responses in a browser, which nobody revisits.
  • A long Max-Age reduces preflights and delays the propagation of a tightened configuration to clients that cached the old answer.
  • Simple requests avoid preflights and constrain you to form encodings and safelisted headers, which is often worse for the API contract than the round trip is for latency (Which API Style Should I Use?).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe simple-versus-preflighted classification, the credentials rule and the header set are specified and behave the same across Blink, Gecko and WebKit; what differs is the wording of the console message, not the decision.
  • BROWSER-SPECIFICThe cap applied to Access-Control-Max-Age differs substantially between engines — Chromium clamps it far lower than the value most servers send, and Firefox uses a different ceiling — so preflight caching duration is never the number you configured.
  • NETWORK-SPECIFICThe cost of a preflight is one round trip, so it is invisible on a local network and clearly visible on a high-latency mobile connection; the same configuration is a non-issue for one population and a real regression for another.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — why a cached negotiation result and a changed server configuration disagree for a window, and how long that window really is.