CORS in Flask: flask-cors and the credentials trap
Flask sends no CORS headers on its own, so almost everyone reaches for flask-cors, the de facto standard extension. Its defaults are fine by themselves. The trap is one line most tutorials add without changing anything else: supports_credentials=True, left next to the untouched default origin, turns every request's Origin header into an allowed one. We installed flask-cors 6.0.5 on Flask 3.1.3, read the option defaults in its source, and confirmed the combination with curl instead of taking that on faith.
What Flask sends with nothing installed
A bare Flask 3.1.3 route with no CORS extension answers a normal GET with no Access-Control-Allow-Origin header at all, and the browser blocks the page from reading it even though the server returned 200 and the right body:
curl -i -H "Origin: https://example.com" http://127.0.0.1:5000/api/data
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 12
{"ok": true}
Flask's built-in Werkzeug server does answer an OPTIONS preflight, because Flask auto-generates an OPTIONS handler for every route. But that handler only exists to report which methods a route accepts; it has nothing to do with CORS:
curl -i -X OPTIONS -H "Origin: https://example.com" \
-H "Access-Control-Request-Method: GET" http://127.0.0.1:5000/api/data
HTTP/1.1 200 OK
Allow: GET, OPTIONS, HEAD
Content-Length: 0
200 and an Allow header, no Access-Control-Allow-Methods, nothing a browser treats as a successful preflight. Django has the same gap, covered in CORS in Django: setting up django-cors-headers; Flask's answer is flask-cors.
Installing flask-cors
The package is maintained at corydolphin/flask-cors on PyPI as flask-cors; the current release is 6.0.5.
pip install flask flask-cors
from flask import Flask, jsonify
from flask_cors import CORS
app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": "https://example.com"}})
@app.route("/api/data")
def data():
return jsonify({"ok": True})
resources scopes the configuration to a URL pattern instead of the whole app; origins inside it takes a string, a list, or a regular expression. A single route can also opt in on its own with the @cross_origin() decorator placed below @app.route, without touching the rest of the app.
The default origin isn't "*" — it's an echo
flask-cors ships with origins defaulting to "*", but we read the option defaults in flask_cors/core.py and send_wildcard also defaults to False. In practice that means a bare CORS(app) doesn't send the literal string * back; it mirrors whatever Origin the request sent, and adds Vary: Origin so caches don't mix responses for different callers:
curl -i -H "Origin: https://another-site.test" http://127.0.0.1:5000/api/data
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://another-site.test
Vary: Origin
curl -i http://127.0.0.1:5000/api/data
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Only a request with no Origin header at all gets the literal wildcard. For a public, read-only, non-credentialed API this distinction barely matters: either way, any site can read the response, which is exactly what an unrestricted origins="*" is supposed to mean.
supports_credentials=True quietly opens the door
Browsers have a backstop for exactly this situation: a response can't pair a literal Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true, so a wildcard can never authorize a credentialed request. flask-cors's reflection default walks straight around that backstop, because the header it sends is never the literal wildcard. We left origins untouched and added only supports_credentials=True, the line every "add cookie-based auth" tutorial tells you to add:
CORS(app, supports_credentials=True)
curl -i -H "Origin: https://attacker.test" http://127.0.0.1:5000/api/data
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://attacker.test
Access-Control-Allow-Credentials: true
Vary: Origin
attacker.test was never listed anywhere. It got back its own origin, reflected, with credentials explicitly allowed, because origins was still at its default. A page on that origin sending fetch(url, { credentials: "include" }) would ride the victim's session cookie into the request and be allowed to read the JSON back. The fix is one line: set origins to an explicit list whenever supports_credentials is on.
CORS(app, supports_credentials=True, origins=["https://app.example.com"])
With that change, the same request from attacker.test gets no Access-Control-Allow-Origin at all, and the credentialed read fails the way it's supposed to.
A path outside your resources pattern reads as a CORS error
Scoping resources to r"/api/*" protects the rest of the app, but it has a side effect worth knowing about before you're debugging it at 11pm. A request to a path that doesn't match the pattern gets no CORS headers at all, even on a server that has flask-cors installed and configured correctly for every other route:
curl -i -H "Origin: https://example.com" http://127.0.0.1:5000/api/dta
HTTP/1.1 404 NOT FOUND
Content-Type: text/html; charset=utf-8
That's a typo'd path, a plain 404, nothing to do with CORS. But the browser console doesn't know that; a fetch call to a path with no Access-Control-Allow-Origin reports a CORS failure, not a 404, so the actual cause (a wrong URL, or a route that needs adding to resources) stays hidden behind a misleading error, a pattern covered more generally in why am I getting a CORS error?. The same thing happens, correctly, for an origin that genuinely isn't allowed: a request from an origin outside the configured list gets a normal 200 from the server with no CORS header, and the browser blocks the read exactly where it should.
Auth checks don't break preflight the way they do in Fastify
We also checked the failure mode we found in CORS in Fastify, where an auth hook registered before the CORS plugin silently killed every preflight. flask-cors doesn't have that problem by default. We added a before_request handler that calls abort(401) for any /api/ request without an API key, and left the preflight itself alone:
@app.before_request
def require_auth():
if request.path.startswith("/api/") and request.method != "OPTIONS":
if request.headers.get("X-Api-Key") != "secret":
abort(401)
The real request without a key still comes back 401, but with Access-Control-Allow-Origin attached, so the browser lets the page read the error and show it to the user instead of reporting a CORS failure:
curl -i -H "Origin: https://example.com" http://127.0.0.1:5000/api/data
HTTP/1.1 401 UNAUTHORIZED
Access-Control-Allow-Origin: https://example.com
That works because intercept_exceptions defaults to True: flask-cors wraps Flask's error handling and adds its headers to error responses too, not only to the happy path. It's one default working in your favor to offset the one that doesn't.
When flask-cors isn't the layer you need
flask-cors only touches responses your own Flask app sends. It does nothing for a third-party API that doesn't send CORS headers of its own, since that's a server you don't control. If you already have a Flask backend, the usual fix is a route that calls that API server-side and returns the result, so the browser only ever talks to your own origin. A CORS proxy earns its place only when there's no backend at all, a case walked through in when not to use a CORS proxy.