Skip to content
Path to Engineer
All lessons
PythonBackend14 min read

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.

Day 20 of the 488-day pathway. Published in full — nothing held back.

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. curl ignores 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 Origin header; the server answers with Access-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/json and usually an Authorization header, essentially every modern API call is preflighted. The browser sends OPTIONS first, asking permission for the method and headers, and the server answers with what it allows — which should include Access-Control-Max-Age so 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 Authorization header, 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, and OPTIONS must never require credentials.

Q: Cookies aren't arriving from your front end even though CORS is configured. Why?

Almost certainly allow_origins=["*"] together with allow_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 sends credentials: "include".


Mini task

  1. Serve a page from http://localhost:3000 and your API on :8000. Fetch across them and read the exact console error.
  2. Check the API's logs — confirm the request ran anyway. (This is the whole lesson.)
  3. curl the same endpoint and confirm it works. CORS did not stop that.
  4. Add Access-Control-Allow-Origin by hand in your Day 004 server. Watch it start working.
  5. Switch to Content-Type: application/json and find the OPTIONS request in the Network tab.
  6. Add Access-Control-Max-Age and confirm the OPTIONS stops repeating.
  7. Reproduce the * + credentials bug in FastAPI, then fix it by naming the origin.
  8. Return X-Total-Count, fail to read it in JS, then add Expose-Headers.
  9. Put an auth check in front of everything and reproduce the 401 preflight.
  10. Configure a dev proxy so both run on one origin and watch CORS disappear entirely.

Exit questions

Answer aloud, no notes.

  1. Define an origin. Are subdomains the same origin?
  2. What exactly does the same-origin policy block — and what does it not block?
  3. Why does it exist? Give the bank.com/evil.com story.
  4. What is CORS, in one sentence, without saying "blocks"?
  5. Does a CORS error mean your server never ran the request?
  6. Why is CORS not API security?
  7. Can the front end fix a CORS error?
  8. What makes a request "simple"? Why is almost nothing simple?
  9. What is a preflight, and what does the server answer with?
  10. Why does Max-Age matter?
  11. State the credentials rule and the FastAPI bug it causes.
  12. Why does a preflight return 401, and what is the fix?
  13. How do you read a custom response header from JavaScript?
  14. 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