flask-cors
flask-cors adds the response headers a browser needs before it will let JavaScript on one origin read a response from your Flask app on another. Import CORS, call CORS(app), and the extension attaches an after_request hook that stamps Access-Control-Allow-Origin and friends onto every response, plus a handler that answers the OPTIONS preflight request browsers send before non-simple calls. You can scope it: a resources dictionary maps URL patterns to their own settings, and a @cross_origin() decorator configures a single view. The library's stated philosophy is that when you want CORS you usually want it turned on for a whole domain, so the defaults are wide open and narrowing them is your job.
For a Flask API that a browser calls from another origin, this is the standard answer and it works with one line. Treat that one line as a starting point, not a finish line: set explicit origins, never pair a wildcard with credentials, and make sure nothing in front of Flask is setting the same headers.
Use it if
- You have a Flask API and a separate frontend origin (a Vite dev server on port 5173, a React app on a different domain, a mobile web build) and the browser console is showing blocked-by-CORS errors
- You need different rules per path: public read endpoints open to any origin, an /admin/* prefix limited to your own domain, each with its own allowed methods and headers
- Your API sends cookies or Authorization headers cross-origin and you need supports_credentials plus a correctly echoed single origin, which is fiddly enough by hand that a library is worth it
- You want preflight handling done for you, including caching preflights with max_age so browsers stop sending an OPTIONS request before every PUT
- You think this makes your API more secure. It does the opposite by design: CORS is a browser restriction, and this extension relaxes it. It stops nothing coming from curl, a script, or another server, and CORS(app) with no arguments tells every website on the internet that its JavaScript may read your responses
- Something in front of Flask already sets CORS headers. nginx, an API gateway, CloudFront, or an ALB adding Access-Control-Allow-Origin on top of what flask-cors sends produces two copies of the header, which browsers reject outright, and the resulting error message does not tell you there are two
- You plan to combine origins="*" with supports_credentials=True. The spec forbids that combination, so the extension echoes back the requesting origin instead, which means any site your users visit can make credentialed requests to your API with their cookies attached
- The path-matching layer has a history: 6.0.0 shipped fixes for CVE-2024-6839 (resource patterns sorted by the wrong specificity), CVE-2024-6844 (unquote_plus decoding paths too eagerly) and CVE-2024-6866 (case-insensitive path matching), and 5.0.0 was a breaking release to stop sending Access-Control-Allow-Private-Network by default. Complicated resources regexes are exactly where those bugs lived, so keep configuration simple and the version current
- You are not on Flask. Starlette and FastAPI ship CORSMiddleware in the framework, Django has django-cors-headers, and Quart has quart-cors; none of them need this
- Your rules are one origin and one prefix. A six-line after_request function that sets three headers is something your whole team can read, and it will never surprise you with pattern-precedence semantics
Setup reality
pip install flask-cors is small: it needs flask>=0.9, Werkzeug>=0.7, and typing_extensions on Python older than 3.11, and supports Python 3.9 through 3.13. CORS(app) genuinely works in one line. The friction is everything after that. Ordering matters, because CORS(app) walks the app's registered URL rules, so blueprints registered after that call may not pick up the resources patterns you expected; register blueprints first or apply CORS to the blueprint itself. Preflight interacts with Flask's own automatic OPTIONS handling and with strict_slashes, so a route defined as /api/items can preflight-fail for a request to /api/items/ before your view is ever reached. Errors raised by other extensions can bypass the after_request hook unless intercept_exceptions stays on. And when nothing works, the documented move is to turn on the library's own logger with logging.getLogger('flask_cors').level = logging.DEBUG, which prints exactly which resource pattern matched and which headers it decided to send. Version 6.0.0 changed the order in which overlapping resource patterns win, so upgrading from 5.x can silently change which config applies.
Patterns
Turn CORS on for the whole appenable-for-everything
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route("/")
def hello():
return "Hello, cross-origin-world!"This allows every origin and every method on every route. It is the right way to unblock a local frontend during development and the wrong thing to deploy; the next pattern is what production should look like.
Allow only the origins you ownrestrict-origins
CORS(
app,
origins=["https://app.example.com", "http://localhost:5173"],
methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)Origins must include the scheme and the port when it is not the default; https://app.example.com and http://app.example.com are different origins to a browser. allow_headers has to list every custom header your client sends, or the preflight fails on that header alone.
Different rules for different URL prefixesper-resource-config
CORS(app, resources={
r"/api/public/*": {"origins": "*"},
r"/api/private/*": {
"origins": ["https://app.example.com"],
"supports_credentials": True,
},
})Keys are matched as regular expressions against the request path. When two patterns both match, 6.0.0 changed the tie-break to most-specific-first, so an upgrade from 5.x can change which block applies. Keep the patterns non-overlapping if you can.
Configure a single viewdecorator-per-route
from flask_cors import cross_origin
@app.route("/webhook", methods=["POST", "OPTIONS"])
@cross_origin(origins=["https://partner.example.com"], max_age=600)
def webhook():
return {"ok": True}The decorator goes below @app.route, closest to the function. Include OPTIONS in the route's methods list, or Flask may answer the preflight itself before the decorator gets a chance to add headers.
Allow cookies and Authorization across originscredentials-and-cookies
CORS(
app,
origins=["https://app.example.com"],
supports_credentials=True,
)
# and on the browser side:
# fetch(url, { credentials: "include" })Never pair supports_credentials=True with a wildcard origin. The spec rejects that combination, so flask-cors echoes whatever Origin the caller sent, which effectively allows any site to make authenticated requests with your users' cookies. Also set SameSite=None; Secure on the cookies themselves.
Match a family of subdomainsregex-origins
CORS(app, origins=[r"https://.*\.example\.com$"])Patterns that look like regular expressions are matched with re.match, which anchors at the start but not the end, so leaving off the trailing $ lets https://app.example.com.attacker.net through. Plain strings without regex characters are compared exactly instead, case-insensitively.
Let the browser read your custom response headersexpose-response-headers
CORS(
app,
origins=["https://app.example.com"],
expose_headers=["X-Total-Count", "X-Request-Id", "Content-Disposition"],
)By default JavaScript can only read a handful of standard response headers. Pagination counts, request ids, and download filenames are invisible to fetch() until you list them here, which is the cause of the classic "the header is in devtools but response.headers has nothing" report.
Stop the browser preflighting every requestcache-preflight
CORS(
app,
origins=["https://app.example.com"],
methods=["GET", "POST", "PATCH", "DELETE"],
max_age=86400,
)max_age sets Access-Control-Max-Age in seconds, so the browser reuses the preflight result instead of sending an OPTIONS before every call. Browsers cap it themselves (Chromium at two hours, Safari lower), so a large value is a request, not a promise.
Apply CORS to one blueprintblueprint-scoped
from flask import Blueprint
from flask_cors import CORS
api = Blueprint("api", __name__, url_prefix="/api")
CORS(api, origins=["https://app.example.com"])
app.register_blueprint(api)Scoping to the blueprint avoids the ordering problem where CORS(app) runs before later blueprints are registered. Version 6.0.5 specifically restored Blueprint in the type signatures, so mypy accepts this again.
Drive settings from app config or environmentconfigure-via-app-config
app.config["CORS_ORIGINS"] = os.environ["ALLOWED_ORIGINS"].split(",")
app.config["CORS_SUPPORTS_CREDENTIALS"] = True
app.config["CORS_EXPOSE_HEADERS"] = ["X-Total-Count"]
CORS(app)Every keyword argument has a CORS_-prefixed config equivalent, which keeps environment-specific origin lists out of the code. Arguments passed directly to CORS() win over config values, so do not set the same thing in both places.
See which rule matched and what was sentdebug-preflight-failures
import logging
logging.basicConfig()
logging.getLogger("flask_cors").level = logging.DEBUGThis is the documented troubleshooting step and it is far faster than guessing. The log lines show the matched resource pattern and every header the extension decided to add, which immediately tells you whether the problem is your config or something else rewriting the response.
Check nothing in front of Flask is adding the same headersavoid-duplicate-headers
curl -i -X OPTIONS https://api.example.com/api/items \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST"
# look for more than one of these in the output:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Origin: *Two Access-Control-Allow-Origin headers make the browser reject the response even though each one is valid on its own. If your proxy adds them, remove the proxy rules and keep flask-cors, or remove flask-cors and keep the proxy rules, but never both.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| starlette | PyPI | You are on Starlette or FastAPI, where CORSMiddleware is built in and needs no extra package. |
| django-cors-headers | PyPI | The same job on Django, with settings-based allowlists and regex origin support wired into the middleware stack. |
| quart-cors | PyPI | You moved a Flask app to Quart for async and need the same extension shape against Quart's ASGI request cycle. |