The browser console shows one of these:

Access to fetch at 'http://localhost:5000/api/items' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Access to fetch at 'http://localhost:5000/api/items' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.

The request reached the server, but the response did not carry an Access-Control-Allow-Origin header matching the page’s origin, so the browser discarded it. This is a browser rule, not a Flask exception. Nothing is raised in Flask itself, which is why the server log often looks normal.

TLDR

  • No 'Access-Control-Allow-Origin' header is present on every call: Flask-CORS is not installed, not applied to the app, or resources does not match the URL. Fix with CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000"]}}).
  • Redirect is not allowed for a preflight request: the OPTIONS request got a 301/308, usually from a missing or extra trailing slash. Make the frontend URL match the route exactly.
  • must not be the wildcard '*' when the request's credentials mode is 'include': list explicit origins and set supports_credentials=True.
  • CORS error only on failing requests (500, 413, 502): the error response never got CORS headers. Fix the underlying error and check the server log.
  • Works in curl and Postman: expected. Only browsers enforce CORS.

Confirm what the server actually sends

Reproduce the browser’s request with curl, including the Origin header, and read the response headers:

curl -i -H "Origin: http://localhost:3000" http://localhost:5000/api/items

A working setup includes Access-Control-Allow-Origin: http://localhost:3000 in the response. For the preflight that browsers send before JSON or Authorization requests:

curl -i -X OPTIONS http://localhost:5000/api/items \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

You want a 200 or 204 with Access-Control-Allow-Origin and Access-Control-Allow-Methods. If the status is 301, 302, 308, 401 or 404, that is the cause, whatever the browser message says.

Flask-CORS is not applied to the route

Flask does not add CORS headers by itself. Without the extension, or with a pattern that does not cover the URL, the response has no header at all.

from flask import Flask, jsonify
from flask_cors import CORS

app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000"]}})

@app.get("/items")          # URL is /items, not /api/items
def items():
    return jsonify([1, 2, 3])

/items does not match /api/*, so no header is added. Either move the route under /api, or widen the pattern (r"/*"). The same applies to blueprints: the pattern is matched against the final URL path including url_prefix, not the blueprint name.

Origins must match exactly, including scheme and port. http://localhost:3000, http://127.0.0.1:3000 and https://localhost:3000 are three different origins, and a trailing slash in the configured value ("http://localhost:3000/") will not match what the browser sends.

For a single view you can use the decorator instead:

from flask_cors import cross_origin

@app.get("/public")
@cross_origin(origins=["http://localhost:3000"])
def public():
    return jsonify(ok=True)

If you create the app in a factory, call CORS(app, ...) inside the factory, or create cors = CORS() at module level and call cors.init_app(app, ...). A CORS(app) call on a different app object does nothing for the one you serve.

The preflight request is redirected or rejected

Browsers send an OPTIONS preflight for requests with Content-Type: application/json, an Authorization header, or methods such as PUT and DELETE. The preflight must return a successful status directly. Redirects are not followed.

@app.post("/api/items/")      # note trailing slash
def create_item():
    ...

A frontend calling POST /api/items (no slash) gets a 308 redirect to /api/items/ from Flask’s routing. For a plain GET that is usually invisible, but for a preflight the browser reports Redirect is not allowed for a preflight request. Define the route without the trailing slash, or call it with the slash the route defines.

Anything in front of Flask can also answer OPTIONS first. An authentication decorator or before_request hook that returns 401 for missing credentials will reject the preflight, because browsers do not attach Authorization to it. Skip auth for OPTIONS:

from flask import request

@app.before_request
def require_token():
    if request.method == "OPTIONS":
        return None
    ...

The same goes for nginx, API gateways and other proxies: they must either pass OPTIONS through to Flask or add the headers themselves.

Wildcard origin with credentials

Sending cookies or an Authorization header with fetch(..., { credentials: "include" }) makes the browser refuse a wildcard origin:

Access to fetch at 'http://localhost:5000/api/me' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Name the origins and turn credentials on:

CORS(
    app,
    origins=["http://localhost:3000", "https://app.example.com"],
    supports_credentials=True,
)

With cross-site cookies, the session cookie also needs SESSION_COOKIE_SAMESITE = "None" and SESSION_COOKIE_SECURE = True in Flask’s config, or the browser will not send it even once CORS passes. That is a cookie rule rather than a CORS one, and the symptom is a request that succeeds but arrives unauthenticated.

The CORS error hides a failing request

When the response is a 500, or comes from a proxy because the worker died or the body exceeded a size limit, the browser may show only the CORS message, because that response has no CORS headers. The real error is in the server log:

Traceback (most recent call last):
  File "/app/venv/lib/python3.12/site-packages/flask/app.py", line 1511, in wsgi_app
    response = self.full_dispatch_request()
  File "/app/venv/lib/python3.12/site-packages/flask/app.py", line 919, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/app/venv/lib/python3.12/site-packages/flask/app.py", line 917, in full_dispatch_request
    rv = self.dispatch_request()
  File "/app/venv/lib/python3.12/site-packages/flask/app.py", line 902, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "/app/api/items.py", line 14, in create_item
    name = request.json["name"]
KeyError: 'name'

Open the Network tab and look at the status of the failed request before touching CORS settings. A 4xx or 5xx status means the CORS message is a side effect. Pasting a trace like this into the Debugly formatter separates the Flask frames from your own create_item frame, which is the line to fix. For this particular error see Flask KeyError in request data, and for worker crashes behind a proxy see Flask Gunicorn worker timeout.

Duplicate or conflicting headers

If both Flask-CORS and a reverse proxy add Access-Control-Allow-Origin, the response can contain the header twice, and browsers reject it with a message saying the header contains multiple values. Configure CORS in one place only. Calling CORS(app) and also decorating views with @cross_origin is redundant, and giving them different origin lists makes the result hard to reason about.