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

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.