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"
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.
| URL | Same as https://example.com/a? | Different |
|---|---|---|
https://example.com/b | Same | Path is irrelevant |
http://example.com/a | Different | scheme |
https://api.example.com/a | Different | host |
https://example.com:8443/a | Different | port |
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.
| Term | Meaning | Context |
|---|---|---|
| Origin | The triple of scheme + host + port | Browser security boundary |
| Origin server | The server holding the source rather than a cache | CDN / 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.
| Operation | Against 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 fetch | Can't (needs CORS permission) |
Read the target's document | Can'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.
| Header | Meaning |
|---|---|
Access-Control-Allow-Origin | Origin permitted to read. * or one specific value |
Access-Control-Allow-Credentials | Whether requests with credentials are permitted |
Access-Control-Allow-Methods | Preflight response. Permitted methods |
Access-Control-Allow-Headers | Preflight response. Permitted request headers |
Access-Control-Max-Age | Seconds to cache preflight results |
Access-Control-Expose-Headers | Response 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.
| Condition | Range that skips preflight |
|---|---|
| Method | GET / HEAD / POST |
| Headers | Only a specific few (Accept, Accept-Language, Content-Language, Content-Type, etc.) |
Content-Type | text/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.
| Observation | Where it broke | What to suspect |
|---|---|---|
OPTIONS returns 4xx / 5xx | Preflight | Server not handling OPTIONS (routing, auth middleware) |
OPTIONS returns 200 but main request doesn't fire | Preflight response content | Allow-Methods / Allow-Headers missing required values |
| Main request returns 200 but errors | Response headers | Missing or mismatched Allow-Origin |
| Only credentialed requests fail | Combination | Allow-Origin: * with Allow-Credentials: true |
Header becomes undefined | Read restriction | Not 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.
OPTIONSfired; server returnedAllow-Methods: DELETE→ preflight clearedDELETEfired; server deleted and returned 200 → processing complete- 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/jsonleaves it Allow-Origin: *andAllow-Credentials: truecan'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"
- Laravel API Development Guide — Middleware and rate limiting — CORS setup and implementation in the framework
- MDN — CORS
- RFC 6454 — The Web Origin Concept — Definition of origin
- Fetch Standard — CORS protocol — The authoritative source on preflight conditions and headers
What to read next
- Cookies — The next boundary in this part, dividing on a different unit from origins
- TLS and certificates — Another boundary on the same channel
- Common web attacks and defenses — CSRF exploits "sending isn't blocked"
- HTTP caching and CDNs — What happens on a shared cache when
Varyis missing