Skip to main content

Origins and CORS — what the browser draws its lines around

When people hit a CORS error, many read it as "the server rejected the request". In fact, the server has usually processed the request and returned 200 — it's the browser that's discarding the response.

This misreading happens because what the same-origin policy blocks is counterintuitive. It blocks reading, not sending.

What you'll learn in this chapter​

  • Origin as the triple of scheme + host + port
  • The same-origin policy blocks reading, not sending — which is why CSRF succeeds
  • The conditions that trigger preflight, and the range that doesn't
  • Triaging CORS errors by "where it broke"
Prerequisites

Builds on HTTP headers from HTTP and cookie auto-send from Cookies.

What is an origin​

An origin is decided by three elements (defined in RFC 6454).

https :// example.com : 443
scheme host port

All three must match for same-origin.

URLSame as https://example.com/a?Different
https://example.com/bSamePath is irrelevant
http://example.com/aDifferentscheme
https://api.example.com/aDifferenthost
https://example.com:8443/aDifferentport

Forgetting that port counts is common. Local development hitting localhost:8080 from localhost:3000 is cross-origin.

A different concept from "origin server"​

"Origin server" in HTTP caching and CDNs means the source server as opposed to caches.

TermMeaningContext
OriginThe triple of scheme + host + portBrowser security boundary
Origin serverThe server holding the source rather than a cacheCDN / cache hierarchy

Same root word, but they're used in completely different judgments. Confusing them yields non-questions like "does putting a CDN in front change the origin". If the CDN serves under the same hostname, the browser-visible origin doesn't change.

What the same-origin policy blocks​

What it blocks is reading responses from another origin. Sending isn't blocked.

OperationAgainst another origin
Load an image with <img src>Can do (can display, can't read pixels)
Submit a <form>Can do
Load and run with <script src>Can do
Read response body via fetchCan't (needs CORS permission)
Read the target's documentCan't

That asymmetry is the very condition that makes CSRF succeed. The attacker's site can submit a form, and cookies are automatically attached. The response can't be read, but state can be changed.

Meanwhile, adding a custom header to fetch triggers preflight. The main request doesn't fly unless the server permits it. So "check for the presence of a custom header" works as a CSRF defense. It uses the asymmetry: form submission isn't blocked, but custom headers are.

CORS is a mechanism to loosen restrictions​

CORS (Cross-Origin Resource Sharing) doesn't add restrictions. It's a mechanism to let default-forbidden reads through only when the server permits.

Permissions are declared in response headers.

HeaderMeaning
Access-Control-Allow-OriginOrigin permitted to read. * or one specific value
Access-Control-Allow-CredentialsWhether requests with credentials are permitted
Access-Control-Allow-MethodsPreflight response. Permitted methods
Access-Control-Allow-HeadersPreflight response. Permitted request headers
Access-Control-Max-AgeSeconds to cache preflight results
Access-Control-Expose-HeadersResponse headers JavaScript is allowed to read

The last one is easy to miss. JavaScript can read only a limited set of response headers by default, and custom headers like X-Total-Count, needed for pagination, aren't readable. Only listing them in Expose-Headers makes them readable.

Preflight​

Depending on conditions, an OPTIONS fires before the main request.

Preflight doesn't fire when the request falls within the same range that forms and images have always been able to do.

ConditionRange that skips preflight
MethodGET / HEAD / POST
HeadersOnly a specific few (Accept, Accept-Language, Content-Language, Content-Type, etc.)
Content-Typetext/plain / multipart/form-data / application/x-www-form-urlencoded

Content-Type: application/json falls outside this range. Posting JSON triggers preflight because forms can't send that format — it's outside what was permitted by default.

The Authorization header is also outside. Almost every authenticated API goes through preflight.

Wildcard and credentials cannot be combined​

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The browser refuses this combination. For requests with credentials (cookies or Authorization), you must write a specific origin in Allow-Origin.

The reason is clear. * declares "anyone may read", but responses carrying credentials contain information for that user only. Everyone reading that can't happen.

Since only one specific origin can be written, permitting multiple requires reading the request's Origin and returning dynamically. Here, skipping the allow-list match issues permission to any origin.

Dynamic responses need Vary: Origin​

Responses that change Allow-Origin based on Origin are different content per origin. Putting them on a shared cache causes incidents.

1. Request from app.example.com
→ Access-Control-Allow-Origin: https://app.example.com stored in CDN

2. Request from admin.example.com
→ CDN returns response from step 1
→ Allow-Origin says app.example.com, so browser rejects

Adding Vary: Origin makes the CDN treat responses per Origin as separate caches. This is the same shape as "putting per-user responses on a shared cache leaks other people's data" from HTTP caching and CDNs. What's delivered isn't personal data but a permission header — the only difference.

Triaging CORS errors​

In the DevTools Network tab, decide where it broke first.

ObservationWhere it brokeWhat to suspect
OPTIONS returns 4xx / 5xxPreflightServer not handling OPTIONS (routing, auth middleware)
OPTIONS returns 200 but main request doesn't firePreflight response contentAllow-Methods / Allow-Headers missing required values
Main request returns 200 but errorsResponse headersMissing or mismatched Allow-Origin
Only credentialed requests failCombinationAllow-Origin: * with Allow-Credentials: true
Header becomes undefinedRead restrictionNot listed in Expose-Headers

Row 3 causes the most confusion. The server has completed processing normally. For a DELETE, deletion has run. The browser just refuses to expose the response, giving "errored but the record is gone".

Server-side logs don't show anomalies for this reason. The cause is missing response headers, not the request processing.

Common misconceptions​

"CORS protects the server." — What it protects is the user's browser. Servers are protected by authentication and authorization. Even with CORS permission, authenticated endpoints are guarded by auth.

"CORS errors mean the server rejected." — The server has usually finished. It's the browser that discards.

"With the same-origin policy, CSRF can't happen." — It happens. Same-origin blocks reading, not sending.

"Different port but same host is same origin." — Different origin. Port is part of the triple.

"Allow-Origin: * fixes everything." — Not usable with credentials. Authenticated APIs always need a specific origin.

"Server-to-server communication is subject to CORS." — It isn't. Same-origin is a restriction implemented by the browser. curl and server-side HTTP clients aren't affected.

Exercises​

Q1. Hitting DELETE /api/orders/1 gives a CORS error, but reloading shows the order is gone. What's happening?

Answer and explanation

Answer: Preflight passed, the main request succeeded, and the response is missing Access-Control-Allow-Origin.

Trace the sequence.

  1. OPTIONS fired; server returned Allow-Methods: DELETE → preflight cleared
  2. DELETE fired; server deleted and returned 200 → processing complete
  3. That response has no Allow-Origin (or a mismatched value) → browser refuses to expose it

That is, CORS headers were placed on OPTIONS alone, and forgotten on the actual response. Happens when middleware is scoped only to OPTIONS.

How to spot it: check the DELETE status in the Network tab. 200 means it processed.

Q2. You added a total count to the list API response header, but JavaScript can't read it. The header is visible in the Network tab.

Answer and explanation

Answer: Not listed in Access-Control-Expose-Headers.

For cross-origin responses, the headers JavaScript can read are limited by default. Custom headers (like X-Total-Count) are visible in the Network tab but come back null from response.headers.get().

Access-Control-Expose-Headers: X-Total-Count

DevTools is the browser itself, so it isn't subject to the restriction. Visible and readable are different is the trap.

Same-origin has no restriction, so if development goes through a proxy, this only surfaces in production.

Q3. Only one user gets CORS errors from an API behind a CDN. It clears after some time and recurs.

Answer and explanation

Answer: Allow-Origin is returned based on Origin, but Vary: Origin isn't set.

The CDN stores the first response for a given URL and serves it to subsequent requests to the same URL. The Allow-Origin in that response is the origin of the first request.

Request from app.example.com → Allow-Origin: https://app.example.com is cached
Request from admin.example.com → the cached response is served → origin mismatch, error

"Clears after some time" is because the cache expires and the next requester's origin is used for the refetch. That's why which origin fails flips back and forth.

Adding Vary: Origin makes the CDN treat separate caches per Origin.

Note this is isomorphic to "putting per-user responses on a shared cache" from HTTP caching and CDNs. The principle that only responses identical to everyone should be shared-cached applies to CORS headers too.

Summary​

  • Origin is the triple of scheme + host + port. Port counts
  • The same-origin policy blocks reading. Sending isn't blocked, which is why CSRF succeeds
  • CORS doesn't add restriction — it relaxes the default forbid with server permission
  • Preflight fires when leaving "the range forms and images always could". Posting application/json leaves it
  • Allow-Origin: * and Allow-Credentials: true can't be combined
  • Dynamic responses need Vary: Origin. Missing it lets the CDN serve another origin's permission
  • Even on a CORS error, the server has often completed processing. Triage from "where it broke"
Related references