Chapter 22, Intermediate
CORS
CORS is a rule of the **browser**, not the server. It decides whether a site's JavaScript can read the response from your API. If configured incorrectly, it either blocks your frontend or exposes your backend to any site.
The problem
A origin is the combination of protocol, domain, and port. A frontend in http://localhost:5173 and an API in http://localhost:8000 are origins different. For security, the browser blocks JavaScript from one origin from reading responses from another, unless the server says it allows. Without this, you see in the console the error "blocked by CORS policy".
The server allows it by responding with headers Access-Control-Allow-*. In FastAPI, this is a middleware:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["content-type"],
)
@app.get("/")
def home():
return {"mensagem": "CORS configurado"}
cliente = TestClient(app)
permitida = cliente.get("/", headers={"Origin": "http://localhost:5173"})
print(permitida.headers.get("access-control-allow-origin"), permitida.headers.get("access-control-allow-credentials"))
print(permitida.headers.get("vary"))
http://localhost:5173 true
Origin
The disallowed origin
When the origin does not is on the list, the server still executes the request and responds 200, but without the permission header. It is the browser that then hides the response from JavaScript:
negada = cliente.get("/", headers={"Origin": "http://malicioso.com"})
print(negada.status_code, negada.headers.get("access-control-allow-origin"))
200 None
This means that CORS is not server security. Whoever calls from a script, from curl or from another server completely ignores CORS. It protects the user, in the browser, from a malicious site trying to read their session data from your API. Authentication and authorization remain the responsibility of your API.
The preflight
For "non-simple" requests (a POST with JSON, a DELETE, custom headers), the browser first makes a request with the method OPTIONS: "can I make this request?". This is the preflight. The middleware responds to it on its own:
preflight = cliente.options(
"/",
headers={
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
},
)
print(preflight.status_code, preflight.headers["access-control-allow-methods"])
print(preflight.headers["access-control-max-age"])
negado = cliente.options(
"/", headers={"Origin": "http://localhost:5173", "Access-Control-Request-Method": "DELETE"}
)
print(negado.status_code, negado.text)
200 GET, POST
600
400 Disallowed CORS method
The DELETE is not in allow_methods, and the preflight returns 400: the browser does not send the actual request. The max-age indicates how long the browser can cache the preflight response.
The dangerous configuration: `*` with credentials
Many tutorials show allow_origins=["*"] together with allow_credentials=True, to "make the error disappear". The specification prohibits this combination, because it would hand over the user's session to any site. See what Starlette does with it:
app_aberto = FastAPI()
app_aberto.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app_aberto.get("/")
def aberto():
return {}
qualquer = TestClient(app_aberto).get("/", headers={"Origin": "http://site-qualquer.com"})
print(qualquer.headers.get("access-control-allow-origin"))
http://site-qualquer.com
Instead of refusing, Starlette returns the origin of who asked. The practical effect is to allow any site, with credentials. It's the configuration I see most in production code, and it nullifies the browser's protection.
The rule I follow
List the exact origins of your frontend (
https://app.exemplo.com, plushttp://localhost:5173in development), and read the list from an environment variable (chapter 23). Use["*"]only for public APIs, without cookies or credentials (an open data API). If your API uses session cookies, or if your frontend sends the token in a way that the browser attaches it automatically, the exact list is mandatory.
| What | Purpose |
|---|---|
allow_origins | Which sites can read the response |
allow_methods | Which verbs are allowed between origins |
allow_headers | Which headers the frontend can send |
allow_credentials | If cookies and credentials can accompany the request |
allow_origin_regex | Origins by default (for example, all subdomains of exemplo.com) |
max_age | How long the browser keeps the preflight response |
Exercise 1
Two origins from a text
Write montar_app(origens_csv), that receives a text like "https://a.com,https://b.com" and returns an app with CORS for exactly those origins. Check that both are allowed and a third is not.