CORS
22 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A web page at one address often calls an API at another, and the browser blocks it unless the API says it is fine. CORS is that conversation. Wrong settings either break your front end or open your users to other sites.
Origins and the same-origin rule
CORS (Cross-Origin Resource Sharing) is a set of HTTP headers with which a server tells the browser which other origins may read its replies. The browser enforces it, not the server.
A browser lets a page read replies only from its own origin. An origin is the scheme, the host and the port together. A page at https://shop.example can call https://shop.example/api freely, but another origin needs permission.
from urllib.parse import urlsplit
def origin(url): parts = urlsplit(url) return f"{parts.scheme}://{parts.netloc}"
print(origin("https://shop.example/cart?id=1"))http://shop.example, https://shop.example and https://shop.example:8443 are three different origins. The path does not count.
CORSMiddleware with explicit origins
FastAPI uses Starlette's CORSMiddleware. List the exact origins of your front end. The middleware adds the right headers to replies for those origins and nothing for the others.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware( CORSMiddleware, allow_origins=["https://shop.example"], allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"],)Example 1 · Allowed and unknown origins
from fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewarefrom fastapi.testclient import TestClient
def make_client(**options): app = FastAPI() app.add_middleware(CORSMiddleware, **options)
@app.get("/items") def items(): return ["pen", "book"]
return TestClient(app)
client = make_client(allow_origins=["https://shop.example"])
for origin in ("https://shop.example", "https://evil.example", None): headers = {"Origin": origin} if origin else {} r = client.get("/items", headers=headers) print(origin, r.status_code, r.headers.get("access-control-allow-origin"))https://shop.example 200 https://shop.example https://evil.example 200 None None 200 None
What this code does
- The allowed origin gets
Access-Control-Allow-Origin. The unknown origin gets no header, but the reply is still 200: the server answered, and the browser will refuse to show it. - A request without an
Originheader (curl, a mobile app) is not affected.
Read the origin list from settings, so development (http://localhost:3000) and production differ without code changes. A request from another origin still reaches your route; the browser just refuses to show the reply.
The preflight request
Preflight is an automatic OPTIONS request a browser sends before a cross-origin request that could change data, to ask the server whether that method, those headers and this origin are allowed.
Before a risky request, such as one with an Authorization header or a PUT, the browser first sends an OPTIONS request, the preflight. It asks: may this origin use this method and these headers? Only if the answer is yes does it send the real request.
# what the browser sends firstclient.options("/items", headers={ "Origin": "https://shop.example", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "authorization",})Example 2 · A preflight request and its answer
from fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewarefrom fastapi.testclient import TestClient
def make_client(**options): app = FastAPI() app.add_middleware(CORSMiddleware, **options)
@app.get("/items") def items(): return ["pen", "book"]
return TestClient(app)
client = make_client( allow_origins=["https://shop.example"], allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"],)
ask = {"Origin": "https://shop.example", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "authorization"}r = client.options("/items", headers=ask)print(r.status_code)for name in ("access-control-allow-origin", "access-control-allow-methods", "access-control-max-age"): print(name + ":", r.headers[name])
r = client.options("/items", headers={**ask, "Access-Control-Request-Method": "DELETE"})print(r.status_code, r.text)r = client.options("/items", headers={**ask, "Origin": "https://evil.example"})print(r.status_code, r.text)200 access-control-allow-origin: https://shop.example access-control-allow-methods: GET, POST access-control-max-age: 600 400 Disallowed CORS method 400 Disallowed CORS origin
What this code does
- The answer lists the allowed methods and says how long the browser may cache it.
- A method or an origin that is not allowed gets 400 and a short reason.
The reply carries Access-Control-Allow-Origin, -Allow-Methods, -Allow-Headers and -Max-Age. The browser caches the answer for max-age seconds.
What CORS does not do
CORS does not protect your API. A script, curl or another server ignores it completely. Your login and permission checks do the real protection. CORS only stops other websites from reading replies in a user's browser.
app.add_middleware( CORSMiddleware, allow_origins=["*"], # any site may read replies allow_credentials=True, # and the user's cookies are sent)Example 3 · Wildcard with credentials, and what CORS does not stop
from fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewarefrom fastapi.testclient import TestClient
def make_client(**options): app = FastAPI() app.add_middleware(CORSMiddleware, **options)
@app.get("/items") def items(): return ["pen", "book"]
return TestClient(app)
unsafe = make_client(allow_origins=["*"], allow_credentials=True)safe = make_client(allow_origins=["https://shop.example"], allow_credentials=True)visit = {"Origin": "https://evil.example", "Cookie": "session=abc"}
print("unsafe:", unsafe.get("/items", headers=visit).headers.get("access-control-allow-origin"))print("safe:", safe.get("/items", headers=visit).headers.get("access-control-allow-origin"))
r = safe.get("/items", headers=visit)print("curl still gets:", r.status_code, r.json())unsafe: https://evil.example safe: None curl still gets: 200 ['pen', 'book']
What this code does
- With
*and credentials, the middleware echoes any origin that sends a cookie. That lets any site read the answer with the visitor's login. The fixed list gives no header to the evil origin. - Even with the safe list, a non-browser client still gets 200. CORS is not a lock on the API.
Example 4 · Origins from a setting, and a safe pattern
import re
def parse_origins(text): return [o.strip() for o in text.split(",") if o.strip()]
print(parse_origins("https://shop.example, https://admin.shop.example,,"))
pattern = r"https://[a-z0-9-]+\.shop\.example"for origin in ("https://app.shop.example", "https://shop.example.evil.com", "http://app.shop.example"): print(origin, bool(re.fullmatch(pattern, origin)))['https://shop.example', 'https://admin.shop.example'] https://app.shop.example True https://shop.example.evil.com False http://app.shop.example False
What this code does
- A setting such as
ALLOWED_ORIGINSis split and cleaned. Empty items are dropped. fullmatchneeds the whole origin to fit the pattern, soshop.example.evil.comand thehttpversion are refused.
This pair is dangerous. A wildcard with credentials lets any website read private data using the visitor's login cookie. Use a fixed list whenever credentials are allowed.
Next step
Check your understanding
12 quick questions