CORS — Letting Browsers Call Your API
IntermediateBrowsers block cross-origin responses unless your API opts in via CORS headers — CORSMiddleware with an explicit origin list, allow_credentials rules, and the knowledge that CORS is browser policy, not API security.
Overview
Your Next.js app on localhost:3000 calls your FastAPI on localhost:8000 and the browser throws the error every fullstack developer has met: blocked by CORS policy. The Same-Origin Policy forbids scripts from reading responses from another origin (scheme + host + port) unless that server sends Access-Control-Allow-Origin headers; for "non-simple" requests (JSON POSTs, auth headers) the browser first sends an OPTIONS preflight asking permission. CORSMiddleware answers all of it declaratively — you configure the allowed origins, methods, and headers, driven from Settings so prod and dev differ without code changes. Two things separate seniors from juniors here: the wildcard-with-credentials rule (allow_origins=["*"] silently breaks cookie/auth flows — the spec forbids the combination), and the realisation that CORS protects browser users, not your API — curl ignores it entirely, so auth still does all the real work.
The Failure, the Preflight, and the Fix
One middleware, explicit origins, and the browser's OPTIONS dance succeeds. Origins are exact string matches — scheme, host, AND port; a trailing slash breaks them.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[ # EXACT matches — no trailing slash
"http://localhost:3000", # Next.js dev
"https://aicancode.org", # production frontend
],
allow_credentials=True, # cookies / Authorization allowed
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
max_age=600, # cache preflight verdict 10 min
)
@app.get("/api/courses")
def courses():
return [{"id": 1, "title": "Python A-Z"}]
# What actually happens for fetch("http://localhost:8000/api/courses",
# {headers: {Authorization: "Bearer ..."}}):
#
# 1. Browser preflights (because of the Authorization header):
# OPTIONS /api/courses
# Origin: http://localhost:3000
# Access-Control-Request-Method: GET
# Access-Control-Request-Headers: authorization
# 2. Middleware answers (no route code runs):
# Access-Control-Allow-Origin: http://localhost:3000
# Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
# Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
# Access-Control-Allow-Credentials: true
# 3. Browser proceeds with the real GET; response carries
# Access-Control-Allow-Origin again; your JS can now read it.
# Without step 2's headers → the request may even EXECUTE,
# but the browser refuses to let your JavaScript see the response.The Rules That Bite — Wildcards, Credentials, and What CORS Is Not
The spec forbids * with credentials; drive origins from Settings; and never mistake CORS for security — it constrains browsers, not attackers.
# ── Rule 1: wildcard × credentials = broken ──
# allow_origins=["*"] + allow_credentials=True
# → browsers REJECT the combination (spec: credentialed responses
# must name an explicit origin). Symptoms: works in curl and Postman,
# fails only in the browser — the most misdiagnosed bug in fullstack dev.
# Public no-auth API → "*" is fine. Anything with login → explicit list.
# ── Rule 2: config lives in Settings, not code ──
from app.core.config import get_settings
s = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=s.allowed_origins, # .env: dev localhost; prod real domains
allow_credentials=True,
allow_methods=["*"], allow_headers=["*"], # fine when origins are strict
)
# Adding a staging frontend = edit an env var, not a deploy of new code.
# ── Rule 3: CORS is NOT security ──
# curl -H "Origin: https://evil.example" http://localhost:8000/api/courses
# → 200, full JSON. curl doesn't enforce CORS. Neither do scripts,
# Postman, or an attacker's server.
# CORS protects YOUR USERS' BROWSERS from evil-site.com silently reading
# authenticated responses with their cookies. It does not protect the API.
# AuthN/AuthZ (JWT chapters) remain the only actual access control.
# Debug checklist when "blocked by CORS" strikes:
# 1. exact origin string in the list? (https vs http, port, no trailing /)
# 2. credentials on both sides? (fetch credentials:"include" ↔ allow_credentials)
# 3. custom header listed in allow_headers?
# 4. error mentions preflight? check OPTIONS isn't blocked by auth/proxy
# 5. middleware added BEFORE app starts serving (outermost position)Key Points to Remember
- 1Same-Origin Policy blocks cross-origin reads; CORS headers are the server opting in
- 2Non-simple requests trigger an OPTIONS preflight the middleware answers
- 3allow_origins=["*"] with allow_credentials=True is forbidden by spec — list origins explicitly
- 4CORS constrains browsers only — curl ignores it; auth remains the real gate
Interview Questions
Sign in to ask AriaThe frontend gets "blocked by CORS" but the same call works in Postman — explain precisely why.
What triggers a preflight request, and what question does it ask?
Is a strict CORS policy sufficient protection for an internal API? Argue it.
Ask Aria about CORS — Letting Browsers Call Your API
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.