Flask CORS error on localhost (tested)
Flask sends no CORS headers on its own, so a frontend on another port (say Vite on localhost:5173) is blocked even though Flask logs a 200. Add flask-cors with the exact origin; most remaining errors are an origin mismatch, a redirect, or cookies with *.
1. Plain Flask sends no CORS headers
from flask import Flask
app = Flask(__name__)
@app.post("/api/items")
def items():
return {"ok": True}
c = app.test_client()
H = {"Origin": "http://localhost:5173"}
r = c.post("/api/items", json={}, headers=H)
print("POST", r.status_code, "ACAO:", r.headers.get("Access-Control-Allow-Origin"))
r = c.options("/api/items", headers={**H, "Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type"})
print("OPTIONS", r.status_code, "Allow:", r.headers.get("Allow"), "| ACAO:", r.headers.get("Access-Control-Allow-Origin"))
Output:
POST 200 ACAO: None
OPTIONS 200 Allow: POST, OPTIONS | ACAO: None
Flask answers the request fine (200) and even answers the OPTIONS preflight itself with an Allow header, but it sends no Access-Control-Allow-Origin. The browser therefore blocks your frontend from reading the response: the server log shows 200 while the browser console says "blocked by CORS policy". Unlike FastAPI (which returns 405 to a preflight without middleware), nothing in Flask's log hints at the problem.
2. flask-cors: the origin must match exactly
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
app.logger.disabled = True # keep the 500 traceback out of this output
CORS(app, origins=["http://localhost:5173"])
@app.post("/api/items")
def items():
return {"ok": True}
@app.get("/api/boom")
def boom():
return 1 / 0
@app.get("/api/folder/")
def folder():
return {"ok": True}
c = app.test_client()
pre = {"Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type"}
for origin in ("http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3000"):
r = c.options("/api/items", headers={"Origin": origin, **pre})
print(origin, "->", r.status_code, "ACAO:", r.headers.get("Access-Control-Allow-Origin"),
"| ACAH:", r.headers.get("Access-Control-Allow-Headers"))
O = {"Origin": "http://localhost:5173"}
r = c.get("/api/boom", headers=O)
print("500 response ACAO:", r.status_code, r.headers.get("Access-Control-Allow-Origin"))
r = c.get("/api/folder", headers=O)
print("no trailing slash:", r.status_code, r.headers.get("Location"), "ACAO:", r.headers.get("Access-Control-Allow-Origin"))
Output:
http://localhost:5173 -> 200 ACAO: http://localhost:5173 | ACAH: content-type
http://127.0.0.1:5173 -> 200 ACAO: None | ACAH: None
http://localhost:3000 -> 200 ACAO: None | ACAH: None
500 response ACAO: 500 http://localhost:5173
no trailing slash: 308 http://localhost/api/folder/ ACAO: http://localhost:5173
With CORS(app, origins=[...]) the allowed origin gets Access-Control-Allow-Origin and the requested headers echoed back. Any other origin still gets a 200 but no CORS headers, so the browser blocks it. The usual localhost mismatches: 127.0.0.1 vs localhost, a different port (Vite 5173 vs CRA 3000), or https vs http. An origin is scheme + host + port, with no path and no trailing slash.
Two more lines from the same run: with flask-cors 6.0.5, an unhandled 500 still carried the CORS header, so if the browser reports CORS on a request that failed, check the status code first. And a route declared with a trailing slash answers the slashless URL with a 308 redirect. A preflight that gets a redirect fails in the browser (the Fetch spec requires a 2xx preflight response), so call the exact URL.
3. Cookies: supports_credentials with the default "*"
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app, supports_credentials=True) # origins defaults to "*"
@app.get("/api/me")
def me():
return {"user": "ann"}
r = app.test_client().get("/api/me", headers={"Origin": "http://localhost:5173"})
print("ACAO:", r.headers.get("Access-Control-Allow-Origin"))
print("ACAC:", r.headers.get("Access-Control-Allow-Credentials"))
print("Vary:", r.headers.get("Vary"))
Output:
ACAO: http://localhost:5173
ACAC: true
Vary: Origin
Browsers reject Access-Control-Allow-Origin: * on credentialed requests. With supports_credentials=True, flask-cors echoes the request's origin instead of * and adds Vary: Origin, so cookies work, but that also means any site can make credentialed requests. Pass an explicit origins=[...] list whenever you enable credentials.
Checklist
- Install and enable it:
pip install flask-cors,CORS(app, origins=["http://localhost:5173"]). - Copy the origin exactly from the browser's address bar (scheme, host, port), no trailing slash.
- Check the actual status code in the Network tab: a 500 or 404 can be reported as a CORS error.
- Avoid redirects on API URLs (trailing slashes, http to https).
- Using cookies?
supports_credentials=Trueplus an explicit origin list, andcredentials: "include"on the fetch.
Related: FastAPI CORS error on localhost, CORS preflight (OPTIONS) explained, CORS error messages explained.
Frequently asked questions
Why does Flask return 200 but the browser shows a CORS error?
Flask served the request but sent no Access-Control-Allow-Origin header, so the browser blocks the page from reading it. Install flask-cors and allow your frontend's origin.
Is localhost the same origin as 127.0.0.1 for CORS?
No. flask-cors allowed http://localhost:5173 but sent no CORS headers to http://127.0.0.1:5173; scheme, host and port must all match.
How do I allow cookies with flask-cors?
Use CORS(app, origins=[...], supports_credentials=True) and fetch with credentials: 'include'. Avoid the default '*' origin with credentials.
Written with AI assistance. Measured on 2026-10-23 with Flask 3.1.3, flask-cors 6.0.5 and Python 3.14.7 (Flask test client, python:3-alpine in Docker). Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Python, Flask and flask-cors are free.