CORS
[!note] Notes from Stop Copying CORS Headers. Used claude.ai to generate this. It supposed to be personal notes, but it’s not – aahhhh fuckk!!!.
- 1. the problem being solved
- 2. the attack CORS was designed to prevent — CSRF
- 3. origin — what it actually means
- 4. simple requests vs. preflight
- 5. the CORS headers — what each one actually does
- 6. the four failure modes (diagnosis guide)
- 7. why you can’t fix it from the client side
- 8. quick mental model
- 9. sources
1. the problem being solved
you’ve seen this. a fetch call fails. the console says something about CORS. you google it, paste some headers, maybe it works, maybe you added four and removed two and now you’re scared to touch it.
what’s actually happening? in most cases the server got your request. it responded. the data was ready. your browser is the one blocking you from reading it. doing it on purpose.
and there’s more — for many modern API calls, your browser is sending a completely separate HTTP request before your actual one. one you never wrote. one that doesn’t appear anywhere in your code. if your server doesn’t handle it, your real request never goes out.
CORS is not a curse. it’s a mechanism. and like all mechanisms, once you understand what it’s protecting against, the behavior stops seeming arbitrary.
2. the attack CORS was designed to prevent — CSRF
CSRF: Cross-Site Request Forgery.
you’re logged into your bank. session cookie in the browser, valid. you open another tab — could be anything, doesn’t have to be obviously evil. that page quietly runs this:
fetch("https://bank.com/transfer?to=attacker&amount=9999", {
credentials: "include" // attach cookies!
})
the evil site can’t read your bank’s cookie. but when it makes a request to bank.com, the browser automatically attaches your bank.com cookies. that’s just how cookies work — they travel with requests to their domain regardless of where the request came from.
the bank receives what looks like an authenticated request from you. transfer goes through. you didn’t click anything. modern browsers (Chrome, Edge) have made this specific attack harder via SameSite=Lax cookie defaults. but plenty of older systems are still exposed. the model still holds.
so browsers established a rule: cross-origin responses are blocked by default. CORS isn’t a separate security system. it’s the controlled way to relax that rule when the server says it’s safe to do so.
the server is saying: “I know this origin. I trust it. Let the response through.”
CORS didn’t create the restriction. it created the exception.
the attacker cannot fake the answer. an attacker’s site can fire cross-origin requests, but it cannot forge the server’s response headers. the browser goes to the real server to ask. only the real server can reply. as long as the server is correctly configured, the attacker is locked out of both sides.
3. origin — what it actually means
most developers think origin means domain. it doesn’t.
origin = protocol + hostname + port. all three.
https://api.example.com:443
└─────┘ └─────────────┘ └──┘
proto host port (implied for HTTPS)
the path is irrelevant. change any one of the three → completely different origin.
the localhost trap: localhost:3000 and localhost:8000 are different origins. even on your own machine, the browser has no idea they’re both yours. it applies the same rules it would between two completely unrelated websites. this is why your first CORS error almost always happens in local development. front-end on 3000, back-end on 8000. your own machine. different origins.
postman works. browser doesn’t. why?
a developer hits this and genuinely questions their sanity. same call. works in Postman. fails in browser. haven’t changed a thing.
CORS is a browser feature. it does not exist at the network level.
Postman makes HTTP calls directly to the server. no browser, no concept of an origin, no user session to hijack, no idea what other tabs you have open. same as curl, same as wget. they’re just programs talking to servers. CORS is not their problem.
the same-origin policy only exists in a browser because that’s the only environment where untrusted code from the internet runs alongside your authenticated sessions. the attack needs:
- a victim already logged in (cookie exists)
- untrusted code that can trigger requests
- a browser that automatically attaches credentials
Postman satisfies none of them. there is genuinely nothing to protect against. the browser isn’t being paranoid. it’s being precise.
when debugging: Postman tells you whether the endpoint works. it tells you nothing about CORS. those are separate questions answered by separate tools.
4. simple requests vs. preflight
the browser puts every cross-origin request into two categories.
simple requests go straight through. preflighted requests need permission first.
a request stays simple when ALL of the following hold:
| condition | simple | triggers preflight |
|---|---|---|
| method | GET, HEAD, POST |
PUT, DELETE, PATCH, any other |
| headers | only standard safe set | Authorization, custom headers |
| content-type (if POST) | text/plain, multipart/form-data, application/x-www-form-urlencoded |
application/json ← yes, this |
application/json is not on the simple list. the moment you add it → preflight.
why these specific values? they’re not arbitrary. these are exactly the kinds of requests HTML forms could always make since the early web, before any of this existed. a form posting cross-origin was always possible — server developers were always expected to handle it. the browser doesn’t add friction there because that attack surface already existed.
what a preflight looks like
the browser sends an OPTIONS request — the HTTP method specifically designed for asking a server what it will accept. it’s been in the HTTP spec since 1999.
OPTIONS /api/data HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
the browser is asking: “are you expecting this kind of request? because I’m about to send it.”
the server responds with what it permits:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
any 2xx status works. 200 or 204 are both common. if the real request fits within those permissions, the browser proceeds. you never wrote any of that. your browser did it every time.
5. the CORS headers — what each one actually does
Access-Control-Allow-Origin
on every response — preflight and final. answers the browser’s core question: is this origin allowed to see what came back?
Access-Control-Allow-Origin: * # any origin — public API only
Access-Control-Allow-Origin: https://example.com # specific origin
wildcard is fine for a genuinely public API. not for anything involving user data or sessions.
multiple origins? you can’t comma-separate them. the spec allows one value or wildcard. the pattern is: check the incoming Origin header against an allowlist, echo back the match.
const allowed = ["https://app.example.com", "https://staging.example.com"]
const origin = req.headers.origin
if (allowed.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin)
res.setHeader("Vary", "Origin") // ← don't skip this if behind a CDN
}
the Vary: Origin line matters. without it, a CDN caches the response for one origin and serves it to a request from another. CORS fails intermittently. hard to reproduce, hard to trace.
Access-Control-Allow-Methods
preflight response only. declares which HTTP methods the server accepts.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
if your actual request uses a method not listed here, preflight fails, request never goes out.
Access-Control-Allow-Headers
which request headers are permitted. this is the one that bites you when you switch from GET to POST with a JSON body.
Access-Control-Allow-Headers: Content-Type, Authorization
even if the server handles those headers perfectly — if they’re not declared here, the browser won’t allow them through. the server’s capability and its CORS declaration are entirely separate things.
Access-Control-Allow-Credentials
you’re likely to hit this when moving from JWT tokens to session cookies.
- authorization headers with JWTs: work by default
- cookie-based sessions: stopped working
- this is what changed
# server
Access-Control-Allow-Credentials: true
# client
fetch(url, { credentials: "include" })
both are required. when allow-credentials is true, you cannot use wildcard for allow-origin. the browser rejects that combination. it’s in the spec. the browser is not going to change its mind. if any origin could make credentialed requests, you’re right back to the CSRF problem.
Access-Control-Expose-Headers
the one you find out about after shipping.
even after a CORS check passes and the response reaches your JavaScript, the browser still controls which response headers your code can actually see. the full response is there. the browser just doesn’t show it all to JavaScript by default.
readable by default: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma.
anything outside this list — custom headers, rate limit data, request IDs — returns null when your code tries to read it. no error. no warning. just null.
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
one line. check this before you ship.
6. the four failure modes (diagnosis guide)
open the network tab. look at the actual headers, not the console. the console gives you a summary. the network tab gives you evidence.
| symptom | actual cause | fix |
|---|---|---|
No 'Access-Control-Allow-Origin' header |
server didn’t send the header at all | add CORS middleware / set header |
| CORS error, no OPTIONS in network tab but… wait, OPTIONS 404 | preflight fired, options handler missing | add OPTIONS route or use CORS library |
| CORS error even with header present | origin mismatch — header says example.com, request from api.example.com |
match exact origin including subdomain |
| CORS error only with cookies | credentials + wildcard combination | use specific origin when allow-credentials: true |
7. why you can’t fix it from the client side
the browser is not the problem. the policy lives on the server.
your code works within what the server and browser agreed. it can’t change the policy. it can only make requests that fit inside it. client-side hacks — adding headers in the fetch call, using a proxy, disabling web security in Chrome for dev — these are workarounds, not fixes.
the only real fix: go to the server. tell it to declare the correct origins, methods, and headers. the browser will comply.
your browser is running code from hundreds of websites at once on behalf of a user who’s logged into their bank and their email. the same-origin policy is what keeps those contexts from colliding. CORS is how servers deliberately open a gap in that wall when they actually mean to.
server declares. browser enforces. your job is to make sure those two are aligned.
8. quick mental model
request
│
├─ same origin? → no CORS at all, proceed
│
└─ cross origin?
│
├─ simple request (GET/POST, no custom headers, no JSON CT)?
│ → browser sends it → server responds → browser checks allow-origin
│ ├─ match → JS reads response
│ └─ no match / missing → browser blocks JS from reading
│
└─ preflighted (JSON, Authorization, PUT/DELETE/PATCH)?
→ browser sends OPTIONS first
├─ OPTIONS 404 / no CORS headers → preflight fails → real request NEVER SENT
└─ OPTIONS returns correct headers → preflight passes → real request goes out
→ browser checks allow-origin on real response
├─ match → JS reads response
└─ no match → blocked