CORS and the same-origin policy
Why the browser blocks your request, what a preflight is, and how to fix a CORS error properly instead of setting the wildcard and moving on.
Same-origin policy and CORS — why the browser blocks you, and what preflight is
No parallel track. CORS wastes more developer hours than almost anything else in web development, entirely because people misunderstand what it is.
The two sentences that end the confusion forever: 1. CORS is not a restriction. It is a mechanism for RELAXING a restriction that already exists. 2. CORS protects the USER'S BROWSER, not your server.
curlignores it completely — so it is not, and can never be, a security control for your API.
Part 1 — The same-origin policy, first
AN ORIGIN = SCHEME + HOST + PORT. All three. Exactly.
https://app.example.com:443/a vs
https://app.example.com:443/b ⇒ SAME (path is irrelevant)
http://app.example.com ⇒ DIFFERENT — scheme
https://api.example.com ⇒ DIFFERENT — host (subdomains are different origins!)
https://app.example.com:8443 ⇒ DIFFERENT — port
THE SAME-ORIGIN POLICY: a script from origin A may not READ a response from origin B.
WHY IT EXISTS — hold this scenario, it explains everything:
You are logged into bank.com. You open evil.com.
Without the SOP, evil.com's JavaScript does fetch("https://bank.com/accounts")
⇒ the browser attaches your cookies (Day 019), the bank replies with your balance,
and evil.com READS IT.
⇒ The SOP is what makes "being logged in somewhere" survivable at all.
Note precisely what is blocked: READING the response. The request is often still sent — which is exactly why CSRF exists (Day 019). CSRF and CORS are the two halves of the same fact: the browser will send your credentials cross-origin, but will not show the answer to the script.
Not everything is subject to it. <img src>, <script src>, <link>, <form action> and
<iframe> may all point cross-origin — they just cannot be read by script. That asymmetry is
historical (the web worked that way before the policy existed) and it is why <form> can CSRF you
while fetch cannot.
Part 2 — CORS: the server opting in
CORS lets the SERVER tell the BROWSER: "it is fine, let that origin read my response."
THE SIMPLE CASE:
REQUEST Origin: https://app.example.com browser adds this automatically
RESPONSE Access-Control-Allow-Origin: https://app.example.com
⇒ the browser compares them. Match ⇒ the script may read. No match ⇒ BLOCKED.
THE THREE FACTS THAT DISSOLVE 90% OF CORS CONFUSION:
1. THE REQUEST USUALLY REACHED YOUR SERVER AND RAN.
A "CORS error" in the console does NOT mean the request was blocked before sending —
⇒ your handler ran, your database was written, and the BROWSER refused to hand
the response to the JavaScript. Check your server logs; the row is there.
2. CORS IS ENFORCED BY THE BROWSER, BY NOBODY ELSE.
curl, httpx, Postman, your mobile app, an attacker's script on their own server —
⇒ none of them care. ⇒ CORS IS NOT AUTHENTICATION AND NOT AUTHORISATION.
3. ONLY THE SERVER CAN FIX IT.
⇒ no front-end change can grant permission. The headers come from the server. Always.
⚠ "We restrict the API with CORS" is a security answer that fails an interview. CORS is a browser-side convenience. An attacker never uses a browser.
Part 3 — Preflight
For requests that could have side effects, the browser ASKS PERMISSION FIRST —
with a separate OPTIONS request, BEFORE sending the real one.
SIMPLE (no preflight) requires ALL of:
· method is GET, HEAD or POST
· Content-Type is text/plain, application/x-www-form-urlencoded, or multipart/form-data
· no custom headers
⇒ SO: fetch(url, {method:"POST", headers:{"Content-Type":"application/json"}})
IS PREFLIGHTED — because application/json is not in that list.
⇒ Which is why essentially EVERY modern API call triggers a preflight.
THE PREFLIGHT:
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
THE ANSWER — the server grants specific permissions:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400 ⇐ CACHE IT, or you double every request
Access-Control-Max-Age is the one people forget, and without it every single API call
becomes two round trips. ⚠ Browsers cap it (Chrome ~2 hours), but setting it is still the difference
between one round trip and two, on every request, for every user.
⚠ The credentials rule — the trap you will hit
If the request sends credentials (cookies, or fetch with credentials:"include"):
· Access-Control-Allow-Origin MUST be an EXACT ORIGIN. The wildcard * IS ILLEGAL.
· Access-Control-Allow-Credentials: true is required.
· Allow-Headers and Allow-Methods may not be * either.
# ⚠️⚠️ THE FASTAPI CONFIGURATION THAT SILENTLY DOES NOTHING:
app.add_middleware(CORSMiddleware,
allow_origins=["*"], # ⚠️ with credentials, the browser REJECTS this combination
allow_credentials=True) # ⚠️ ⇒ cookies never arrive, and the error blames CORS
# CORRECT — name the origins:
app.add_middleware(CORSMiddleware,
allow_origins=["https://app.example.com", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
max_age=3600)
This exact combination is the single most common CORS bug in Python backends, and its
symptom is misleading: the request appears to work in Postman, works with allow_credentials=False,
and fails only in the browser with the user logged out.
Also worth knowing: the browser only exposes a handful of response headers to script by
default. To read a custom header like X-Total-Count you must add
Access-Control-Expose-Headers — otherwise it is present on the wire and invisible in JavaScript,
which looks like the server never sent it.
Part 4 — Debugging CORS in sixty seconds
THE PROCEDURE:
1. Open the Network tab. FIND THE **OPTIONS** REQUEST. Is it there? What did it return?
⇒ no OPTIONS at all ⇒ it was a simple request; the problem is the response headers
⇒ OPTIONS returned 404/405 ⇒ your framework/proxy is not handling OPTIONS at all
⇒ OPTIONS returned 401 ⇒ your AUTH MIDDLEWARE is rejecting the preflight —
⚠️ and a preflight carries NO cookies and NO Authorization header, by design.
2. Read the actual browser message. It names the missing header. It is not being coy.
3. Compare Origin (request) with Access-Control-Allow-Origin (response) CHARACTER BY CHARACTER.
⇒ http vs https, a trailing slash, and :3000 vs :5173 are the usual culprits.
4. Credentials involved? ⇒ then * is illegal. Name the origin.
⚠ The 401-on-preflight bug deserves its own line, because it looks like an auth problem and
is an ordering problem: CORS middleware must run before authentication, and OPTIONS must never
require credentials. In FastAPI, CORSMiddleware added last runs outermost — which is what you
want — but a custom auth middleware or a dependency on the router can still swallow the preflight.
In development, a proxy avoids the problem entirely: Vite's server.proxy (or CRA's proxy)
makes the browser see one origin, so CORS never applies. Which is itself the insight — CORS
exists only because of the origin split, and a reverse proxy in production that serves the app and
the API from one origin removes the entire class of problem (Day 018's reverse proxy, earning its
keep again).
Common mistakes
| Mistake | Correction |
|---|---|
| "CORS blocked my request" | It usually ran. The browser blocked reading the response. |
| Treating CORS as API security | Browser-only. curl ignores it entirely. |
| Trying to fix CORS in the front end | Impossible. The headers come from the server. |
allow_origins=["*"] + allow_credentials=True |
Illegal combination — silently drops cookies. |
Forgetting Access-Control-Max-Age |
Every request becomes two round trips. |
| Auth middleware in front of preflight | OPTIONS carries no credentials. 401 on preflight. |
Not handling OPTIONS in the proxy |
404/405 on the preflight, and a confusing console error. |
| Expecting a custom header in JS | Needs Access-Control-Expose-Headers. |
| Assuming subdomains share an origin | They do not. Scheme + host + port, exactly. |
| Confusing CORS and CSRF | CORS is about reading; CSRF is about causing. |
Interview questions
Q: What is the same-origin policy and why does it exist?
A browser rule that a script from one origin — scheme, host and port together — can't read a response from another. It exists because the browser attaches your cookies automatically, so without it any page you visit could fetch your bank's account endpoint with your session attached and read the result. It's what makes staying logged in anywhere survivable.
Q: So what is CORS?
The mechanism a server uses to relax that policy for specific origins. The browser sends an
Originheader; the server answers withAccess-Control-Allow-Origin, and the browser decides whether to expose the response to the script. It's opt-in relaxation, not a new restriction — which is exactly backwards from how most people describe it.
Q: Is CORS a security feature for your API?
No, and this is the part that matters. It's enforced entirely by the browser.
curl, Postman, a mobile app, or an attacker's server-side script never consult it. So CORS protects my users' browsers from other sites reading my responses on their behalf — it does nothing to protect my server. Authorisation still has to be checked in the handler.
Q: What triggers a preflight, and why does nearly everything trigger one?
Anything that isn't a "simple" request: a method beyond GET/HEAD/POST, a custom header, or a content type outside the three form-ish ones. Since a normal JSON API call sends
Content-Type: application/jsonand usually anAuthorizationheader, essentially every modern API call is preflighted. The browser sendsOPTIONSfirst, asking permission for the method and headers, and the server answers with what it allows — which should includeAccess-Control-Max-Ageso the answer is cached rather than paid for on every request.
Q: Your preflight returns 401. What's wrong?
Authentication is running in front of the CORS handling. A preflight is deliberately sent without cookies or an
Authorizationheader, so any middleware that requires credentials will reject it, and the browser reports it as a CORS failure rather than an auth failure. The fix is ordering: CORS handling has to be outermost, andOPTIONSmust never require credentials.
Q: Cookies aren't arriving from your front end even though CORS is configured. Why?
Almost certainly
allow_origins=["*"]together withallow_credentials=True. The spec forbids the wildcard when credentials are involved, so the browser rejects the response — and the symptom is that the user simply appears logged out, while the same call works fine in Postman. The fix is to name the exact origins, and to make sure the front end actually sendscredentials: "include".
Mini task
- Serve a page from
http://localhost:3000and your API on:8000. Fetch across them and read the exact console error. - Check the API's logs — confirm the request ran anyway. (This is the whole lesson.)
curlthe same endpoint and confirm it works. CORS did not stop that.- Add
Access-Control-Allow-Originby hand in your Day 004 server. Watch it start working. - Switch to
Content-Type: application/jsonand find the OPTIONS request in the Network tab. - Add
Access-Control-Max-Ageand confirm the OPTIONS stops repeating. - Reproduce the
*+ credentials bug in FastAPI, then fix it by naming the origin. - Return
X-Total-Count, fail to read it in JS, then addExpose-Headers. - Put an auth check in front of everything and reproduce the 401 preflight.
- Configure a dev proxy so both run on one origin and watch CORS disappear entirely.
Exit questions
Answer aloud, no notes.
- Define an origin. Are subdomains the same origin?
- What exactly does the same-origin policy block — and what does it not block?
- Why does it exist? Give the bank.com/evil.com story.
- What is CORS, in one sentence, without saying "blocks"?
- Does a CORS error mean your server never ran the request?
- Why is CORS not API security?
- Can the front end fix a CORS error?
- What makes a request "simple"? Why is almost nothing simple?
- What is a preflight, and what does the server answer with?
- Why does
Max-Agematter? - State the credentials rule and the FastAPI bug it causes.
- Why does a preflight return 401, and what is the fix?
- How do you read a custom response header from JavaScript?
- How does a reverse proxy make CORS irrelevant?
Articulation drill
Record two minutes: "My React app can't call my API. It says CORS. Explain and fix it."
Start by correcting the frame, because it changes what they look at: the request almost certainly reached the API and ran — check the server log, the row is there. The browser refused to give the response to the JavaScript, because the API never said that origin was allowed.
Then the fix, on the server: name the exact origins, allow the methods and headers actually used,
set Access-Control-Max-Age so the preflight is cached, and if cookies are involved, do not use
* — the combination is illegal and the failure looks like a logged-out user rather than a CORS
problem.
Then close with the sentence that separates you from someone who copied a config: "And CORS
is not protecting the API — curl ignores it entirely. It protects my users' browsers from other
sites reading my responses with their cookies attached. Authorisation still has to be enforced in the
handler."
Previous: Day 019 · Tomorrow: Day 021 — what deployment actually does: build, artefacts, static hosting, CDN, edge · C-14 the networks drill