Single-Page Apps & CORS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3 (released 2026-02-19), Werkzeug 3.1.9, Flask-CORS 6.0.5, Flask-JWT-Extended 4.7.4, Flask-WTF 1.3.0 and Python 3.13, checked on 2026-10-07; every Python example was executed with those versions through Flask’s test client. Flask 3.2 is not released yet and its changelog has no entry that changes anything described here. The page follows the questions that appear when a JavaScript front end meets a Flask back end: where the built front end is served from, how it is developed against the API, what CORS is and how Flask-CORS answers it, why a browser’s preflight request must never be blocked by the authentication code, how the user stays logged in (cookie or token) and how CSRF fits in, and how a React project plugs into all of it. The running scenario is the Bookshelf API from the earlier pages. The browser-side rules of CORS are explained once for the whole site in What is CORS?; this page shows the Flask side.

Three Ways to Put a Front End in Front of Flask

A single-page application (SPA) is a bundle of HTML, JavaScript and CSS that the browser loads once and that then calls a JSON API with fetch. Flask can take three roles around it, and the choice decides whether CORS is needed at all.

flowchart TD subgraph A[Same origin] B1[Browser] --> F1[Flask serves the build and the API] end subgraph P[Same origin through a proxy] B2[Browser] --> N[nginx or CDN] N -->|static files| D[dist folder] N -->|api paths| F2[Flask API only] end subgraph X[Cross origin] B3[Browser] -->|page| H[Static host or Vite dev server] B3 -->|fetch with Origin header| F3[Flask API plus CORS headers] end
Figure 1. A SPA either shares the API's origin (left, no CORS) or lives on another one (right, CORS and credentials rules apply)
Layout Strengths Costs

Flask serves the build and the API (this page, Serving a SPA from Flask)

One process, one origin, no CORS, cookies work with SameSite=Lax or Strict. The simplest deployment and the one the official Single-Page Applications pattern describes.

Python serves static files (slow compared with a proxy), front end and back end are released together, and a catch-all route needs care so API errors stay JSON.

A reverse proxy or CDN serves the build and forwards /api to Flask

Still one origin for the browser, so still no CORS. Static files are served by software built for it, with caching headers set at the proxy.

One more component to configure. See Static Files in Production for the proxy rules.

A separate front-end origin (Vite dev server, S3 and CloudFront, Netlify, a different sub-domain)

Independent releases and teams, the front end can sit on a CDN, one API can serve several clients.

Every request is cross-origin, so CORS must be configured, cookies need SameSite=None or a shared parent domain, and the preflight request doubles the number of round trips for non-simple requests.

During development almost every project is in the third layout (the Vite dev server on port 5173, Flask on port 5000), and the usual remedy is not CORS but a development proxy that makes the browser see one origin (see The Vite Development Proxy). The Full-Stack Flask and React book builds its React project separately from the Flask project and enables CORS in the API; that works, and it is the reason the CORS sections below exist, but a proxy removes the problem where it can be removed.

Serving a SPA from Flask

The official Single-Page Applications pattern places the files produced by the front-end build in a folder of the project, registers that folder as the static folder, and adds a catch-all endpoint that returns index.html for every other path. The catch-all is needed because the SPA’s router (React Router, for example) owns URLs such as /books/42 in the browser’s address bar: when the user reloads the page or opens a bookmarked link, the browser asks Flask for /books/42, which is no file and no API route, and Flask must answer with the application shell so the client-side router can take over. This is called the history fallback.

The pattern in the official page is two routes: the first registered with defaults={"path": ""} for /, the second with <path:path>, both calling app.send_static_file("index.html"). It works, but it hides two problems that the Bookshelf needs to solve: a request for /api/typo must stay a JSON 404, not become the HTML shell with status 200, and a request for a missing /assets/app-1234.js must stay a real 404 (a shell returned as JavaScript produces a confusing syntax error in the browser). The version below turns the fallback into a 404 handler instead of a catch-all rule, so every existing route, including the static route, is tried first.

import pathlib
import tempfile

from flask import Flask, jsonify, request, send_from_directory

# Stand-in for the front-end build output (normally `npm run build` writes dist/).
dist = pathlib.Path(tempfile.mkdtemp()) / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text('<!doctype html><div id="root"></div><script src="/assets/app-3f9a1c.js"></script>')
(dist / "assets" / "app-3f9a1c.js").write_text("console.log('bookshelf');")

# static_url_path="/" serves the build folder at the site root.
app = Flask(__name__, static_folder=str(dist), static_url_path="/")


@app.get("/api/books")
def list_books():
    return jsonify([{"id": 1, "title": "Dune"}])


@app.errorhandler(404)
def history_fallback(error):
    last_segment = request.path.rsplit("/", 1)[-1]
    is_api = request.path.startswith("/api/")
    is_file = "." in last_segment  # /assets/missing.js is a real 404
    if request.method != "GET" or is_api or is_file:
        return jsonify(error="not found"), 404
    return send_from_directory(dist, "index.html", max_age=0)


@app.after_request
def cache_headers(response):
    if request.path.startswith("/assets/") and response.status_code == 200:
        # File names carry a content hash, so they can be cached for a year.
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
    return response


client = app.test_client()
assert client.get("/").mimetype == "text/html"
assert client.get("/books/42").mimetype == "text/html"          # history fallback
assert client.get("/assets/app-3f9a1c.js").mimetype == "text/javascript"
assert "immutable" in client.get("/assets/app-3f9a1c.js").headers["Cache-Control"]
assert client.get("/api/books").json[0]["title"] == "Dune"
assert client.get("/api/typo").json == {"error": "not found"}
assert client.get("/assets/missing.js").status_code == 404
assert client.post("/books/42").status_code == 405  # the static rule only matches GET and HEAD

What the example relies on, and what to adjust:

  • static_url_path="/" makes the static rule /<path:filename>. A rule with a path converter never matches the bare /, so the root falls through to the 404 handler, which returns the shell. If the build is served under a prefix, give the same prefix to the front-end tool (Vite’s base option) and to static_url_path.

  • send_from_directory(dist, "index.html", max_age=0) is Werkzeug’s safe file sender, so no user input reaches the file system. index.html must be revalidated on every visit (the ETag the sender computes makes that cheap) because it names the hashed files of the current build; the hashed files themselves never change and can be cached for a year. The SEND_FILE_MAX_AGE_DEFAULT setting applies one lifetime to every file, which is why the example sets the header per path. See Static Files and Assets for the options.

  • The fallback answers only GET requests. A POST to a page path is an error, 405 Method Not Allowed, because the static rule /<path:filename> matches every path but only allows GET and HEAD; the same rule is why a POST to /api/typo is a 405 too, and why an API that wants JSON for every error also registers a 405 handler.

  • Register the handler on the application, not on a blueprint: a blueprint’s error handlers are not consulted for routing 404 errors, because no blueprint has matched yet (see Blueprints).

  • Everything that reaches Flask passes through before_request hooks, including /assets/. An authentication hook that rejects anonymous requests must exempt the shell and the assets, or the login page cannot load.

  • Flask’s own server of static files is meant for development and small deployments. With a proxy in front, remove the fallback from Python and let the proxy do it (try_files $uri /index.html; in nginx), keeping only /api/ for Flask.

The same layout with the official pattern’s two routes is acceptable for a prototype and shorter to write; the version above is what a project that returns JSON errors needs.

The Vite Development Proxy

When the front end lives in its own project, the Vite dev server serves the page on http://localhost:5173 while Flask answers on http://localhost:5000. For the browser these are two origins (the port differs), so every fetch to Flask is cross-origin. Vite can forward some paths to another server, and when the page calls /api/books on its own origin the dev server relays the request to Flask: the browser only ever talks to localhost:5173, so there is no CORS, no preflight and no cross-site cookie. The setting is server.proxy, an object whose keys are path prefixes (a key that starts with ^ is a regular expression) and whose values are a target string or an options object that extends the http-proxy options.

// vite.config.js (frontend project, next to package.json)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      // http://localhost:5173/api/books  ->  http://127.0.0.1:5000/api/books
      "/api": {
        target: "http://127.0.0.1:5000",
        changeOrigin: false, // keep the Host header of the dev server
      },
    },
  },
});

The Flask side needs nothing special, and the Bookshelf API is started as usual (flask --app bookshelf run --debug, development only). Points that decide whether the proxy is enough:

  • Same prefix everywhere. The paths the front end calls (/api/...) are the same ones nginx forwards in production, so the front-end code has no environment-specific URLs.

  • Host and scheme. With changeOrigin: false Flask sees Host: localhost:5173. That matters for url_for(..., _external=True), redirects and TRUSTED_HOSTS. Set changeOrigin: true to send the target’s host instead, or configure Flask’s ProxyFix when a production proxy supplies X-Forwarded-* headers (see Host Header Validation and Proxies).

  • Cookies. The session cookie is set for localhost without a port, so it works through the proxy exactly as in production. Cookies are keyed by host, not by port, which is also why localhost:5173 and localhost:5000 are same-site although they are cross-origin: the distinction matters for SameSite below.

  • WebSockets and streaming. ws: true in the proxy options forwards WebSocket upgrades; Flask itself needs an extension for those (outside the scope of this page).

  • The proxy is a development tool. vite build produces static files; nothing proxies in production. Production needs the reverse proxy of the previous section, or CORS.

Build the front end with Vite (not Create React App, which was deprecated in February 2025), as described in Front-End Tooling: Vite Builds and a Manifest, and see the React pages of this site, starting with Getting Started with React and React Build and Deployment.

What CORS Asks of Flask

The browser’s same-origin policy lets a page send a request to another origin but hides the response from the page’s script, unless the response carries permission headers. CORS is that permission protocol. Everything below runs in the browser; Flask’s task is to add the right Access-Control-* headers and to answer one extra request. The protocol, step by step, is in What is CORS?; the short version for a Flask developer:

  1. Every cross-origin request carries an Origin header with the page’s scheme, host and port. A scheme, host or port that differs from the API’s is enough to make a request cross-origin.

  2. A simple request (GET, HEAD or POST with only the headers a browser sets itself and a form content type) goes out directly; the response must contain Access-Control-Allow-Origin naming that origin (or *) or the script cannot read it. Flask has already run the view and any side effects by then: CORS does not stop a request being processed.

  3. Any other request (PUT, PATCH, DELETE, a JSON Content-Type, an Authorization header, a custom header such as X-CSRFToken) is preflighted. The browser first sends OPTIONS with Origin, Access-Control-Request-Method and Access-Control-Request-Headers, and only sends the real request if the answer is a success status that grants the method and headers. A preflight never carries cookies or an Authorization header.

  4. A request made with credentials (fetch(..., { credentials: "include" })) needs Access-Control-Allow-Credentials: true and an explicit origin: the wildcard * is refused by the browser for credentialed requests.

sequenceDiagram participant B as Browser with the SPA participant F as Flask API B->>F: OPTIONS /api/books, Origin, Request-Method POST, Request-Headers content-type F-->>B: 200, Allow-Origin, Allow-Methods, Allow-Headers, Max-Age Note over B: the browser checks the grant and caches it for Max-Age seconds B->>F: POST /api/books with JSON body, Origin, cookies if credentials are included F-->>B: 201, Allow-Origin, Allow-Credentials, the JSON body
Figure 2. A preflighted JSON request: the OPTIONS preflight carries no credentials and must succeed before the real request

What Flask does without help: it adds OPTIONS to every route automatically (a view can opt out with provide_automatic_options=False) and answers it with an Allow header listing the route’s methods, but with no CORS headers. A cross-origin page therefore sees a successful preflight with no permission and the browser blocks the real request. The first example writes the mechanism by hand so the extension in the next section is not magic: an after_request function that adds the headers for a list of allowed origins, and a reply to the preflight (which Flask has already built, so only the headers need to be added).

from flask import Flask, jsonify, request

ALLOWED_ORIGINS = {"http://localhost:5173"}

hand = Flask(__name__)


@hand.get("/api/books")
def hand_books():
    return jsonify([{"id": 1, "title": "Dune"}])


@hand.after_request
def add_cors_headers(response):
    origin = request.headers.get("Origin")
    if origin in ALLOWED_ORIGINS:
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers.add("Vary", "Origin")  # the answer depends on the request's Origin
        if request.method == "OPTIONS":
            response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE"
            response.headers["Access-Control-Allow-Headers"] = request.headers.get(
                "Access-Control-Request-Headers", "Content-Type"
            )
            response.headers["Access-Control-Max-Age"] = "600"
    return response


c = hand.test_client()
allowed = {"Origin": "http://localhost:5173"}
r = c.get("/api/books", headers=allowed)
assert r.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
assert "Access-Control-Allow-Origin" not in c.get("/api/books", headers={"Origin": "https://other.example"}).headers
pre = c.options("/api/books", headers={**allowed, "Access-Control-Request-Method": "GET"})
assert pre.status_code == 200 and pre.headers["Access-Control-Max-Age"] == "600"

Copying the request’s Access-Control-Request-Headers back as the allow-list, as the example does, grants whatever the page asks for; a real list is better. Writing the full header logic (regular-expression origins, Vary, Max-Age handling, error responses, credential rules) is what the extension exists for.

Flask-CORS

Flask-CORS is the extension that implements the server side of the protocol. It adds an after_request function to the application, matches the request path against a list of resource patterns, and writes the headers; it also wraps the application’s exception handling so that error responses (including 404 and 500) carry the headers, otherwise a failing API would show up in the browser console as a CORS error that hides the real status. Install it with pip install flask-cors (version 6.0.5, Python 3.9 and newer).

from flask import Flask, jsonify, request
from flask_cors import CORS

app = Flask(__name__)

# Only the API is exposed cross-origin, only to the front end, and cookies are allowed.
CORS(
    app,
    resources={
        r"/api/*": {
            "origins": ["http://localhost:5173"],
            "methods": ["GET", "POST", "PUT", "DELETE"],
            "allow_headers": ["Content-Type", "X-CSRFToken"],
            "expose_headers": ["X-Total-Count"],
            "supports_credentials": True,
            "max_age": 600,
        }
    },
)


@app.route("/api/books", methods=["GET", "POST"])
def books():
    if request.method == "POST":
        return jsonify(id=2), 201
    return jsonify([]), 200, {"X-Total-Count": "0"}


@app.get("/health")
def health():
    return "ok"


client = app.test_client()
origin = {"Origin": "http://localhost:5173"}

simple = client.get("/api/books", headers=origin)
assert simple.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
assert simple.headers["Access-Control-Allow-Credentials"] == "true"
assert simple.headers["Access-Control-Expose-Headers"] == "X-Total-Count"

preflight = client.options(
    "/api/books",
    headers={
        **origin,
        "Access-Control-Request-Method": "POST",
        "Access-Control-Request-Headers": "content-type,x-csrftoken",
    },
)
assert preflight.status_code == 200
assert preflight.headers["Access-Control-Max-Age"] == "600"
assert preflight.headers["Access-Control-Allow-Headers"] == "content-type, x-csrftoken"
assert "POST" in preflight.headers["Access-Control-Allow-Methods"]

# Another origin gets no permission headers at all; the route itself still answers.
stranger = client.get("/api/books", headers={"Origin": "https://evil.example"})
assert "Access-Control-Allow-Origin" not in stranger.headers
# Paths outside the resource pattern are not touched.
assert "Access-Control-Allow-Origin" not in client.get("/health", headers=origin).headers
# A header that is not in allow_headers is dropped from the grant.
refused = client.options(
    "/api/books",
    headers={**origin, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "x-secret"},
)
assert "x-secret" not in refused.headers.get("Access-Control-Allow-Headers", "")

Where to configure it, in order of precedence (the first match wins): the per-resource dictionary, the keyword arguments of CORS(...) or init_app(...), the application’s CORS_* configuration keys, then the defaults. Every option exists in all four places; the configuration key is the option name upper-cased with a CORS_ prefix.

Option (and config key) Default Effect

origins (CORS_ORIGINS)

"*"

Allowed origins: a string, a compiled regular expression, or a list of them. An origin must include the scheme and the port when it is not the default (http://localhost:5173, not localhost). Matching is case-insensitive.

methods (CORS_METHODS)

GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE

Written to Access-Control-Allow-Methods of preflight answers; a preflight that asks for a method outside the list gets no methods header.

allow_headers (CORS_ALLOW_HEADERS)

"*"

Request headers a page may send. Only the headers named in Access-Control-Request-Headers that match this list are granted.

expose_headers (CORS_EXPOSE_HEADERS)

None

Response headers that page scripts may read (X-Total-Count, Location, ETag); the browser hides every other response header except a short safe list.

supports_credentials (CORS_SUPPORTS_CREDENTIALS)

False

Adds Access-Control-Allow-Credentials: true, so cookies and HTTP authentication are honoured. See Credentials and the Wildcard Trap.

max_age (CORS_MAX_AGE)

None

Seconds a browser may cache a preflight answer (timedelta, int or string). Browsers cap it (Chromium at 2 hours, Firefox at 24 hours), so a large value is harmless.

send_wildcard (CORS_SEND_WILDCARD)

False

With origins="*", send * instead of echoing the request’s Origin.

vary_header (CORS_VARY_HEADER)

True

Adds Vary: Origin when the allowed origin depends on the request, so caches do not mix answers for different origins.

automatic_options (CORS_AUTOMATIC_OPTIONS)

True

Only for the cross_origin decorator: make the decorated view answer OPTIONS.

always_send (CORS_ALWAYS_SEND)

True

Send CORS headers even when the request has no Origin header (helps debugging with curl).

intercept_exceptions (CORS_INTERCEPT_EXCEPTIONS)

True

Wrap Flask’s exception handling so error responses carry the headers.

allow_private_network (CORS_ALLOW_PRIVATE_NETWORK)

False

Answer the private-network-access preflight header (Access-Control-Request-Private-Network) with permission. The default became False in release 5.0.0.

resources (CORS_RESOURCES)

r"/*"

Path patterns (regular expressions) that receive CORS handling, as a string, a list, or a dictionary of pattern to options. Most-specific patterns win.

The extension follows the usual extension protocol, so it fits an application factory: create cors = CORS() in extensions.py and call cors.init_app(app, ...) in create_app (see Application Factory and Project Layout). The next example takes the origins from the application’s configuration, which is how the same code serves a development front end and the production site, and shows that an origin regular expression is matched from the start of the Origin value only.

import os
import re

from flask import Flask, jsonify
from flask_cors import CORS

cors = CORS()


def create_app(config=None):
    app = Flask(__name__)
    # BOOKSHELF_ORIGINS="https://shelf.example,https://admin.shelf.example" in production
    origins = os.environ.get("BOOKSHELF_ORIGINS", "http://localhost:5173")
    app.config["CORS_ORIGINS"] = [o.strip() for o in origins.split(",") if o.strip()]
    app.config["CORS_SUPPORTS_CREDENTIALS"] = True
    app.config.update(config or {})
    cors.init_app(app, resources={r"/api/*": {}})  # options come from CORS_* keys

    @app.get("/api/books")
    def api_books():
        return jsonify([])

    @app.get("/other")
    def other():
        return "outside the resource pattern"

    return app


client = create_app().test_client()
granted = client.get("/api/books", headers={"Origin": "http://localhost:5173"})
assert granted.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
assert granted.headers["Access-Control-Allow-Credentials"] == "true"
assert "Access-Control-Allow-Origin" not in client.get("/other", headers={"Origin": "http://localhost:5173"}).headers

# Regular expressions allow one pattern for many sub-domains, but re.match() only anchors the start.
loose = create_app({"CORS_ORIGINS": [re.compile(r"https://[a-z0-9-]+\.shelf\.example")]}).test_client()
strict = create_app({"CORS_ORIGINS": [re.compile(r"https://[a-z0-9-]+\.shelf\.example$")]}).test_client()
lookalike = {"Origin": "https://admin.shelf.example.evil.example"}
assert "Access-Control-Allow-Origin" in loose.get("/api/books", headers=lookalike).headers  # the trap
assert "Access-Control-Allow-Origin" not in strict.get("/api/books", headers=lookalike).headers
assert strict.get("/api/books", headers={"Origin": "https://admin.shelf.example"}).headers["Access-Control-Allow-Origin"]

The same extension object can be applied to a single blueprint, so that only its routes get CORS handling: CORS(blueprint, origins=[...]) or cors.init_app(blueprint, origins=[...]). A blueprint has no config, so the CORS_* application keys are not read in that case and every option comes from the keyword arguments.

from flask import Blueprint, Flask, jsonify
from flask_cors import CORS

api = Blueprint("api", __name__, url_prefix="/api")
CORS(api, origins=["http://localhost:5173"])  # blueprint-only CORS


@api.get("/books")
def blueprint_books():
    return jsonify([])


bp_app = Flask(__name__)
bp_app.register_blueprint(api)


@bp_app.get("/other")
def bp_other():
    return "not a blueprint route"


bp_client = bp_app.test_client()
origin = {"Origin": "http://localhost:5173"}
assert bp_client.get("/api/books", headers=origin).headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
assert "Access-Control-Allow-Origin" not in bp_client.get("/other", headers=origin).headers

A few details worth knowing:

  • An origin regular expression is applied with re.match, which anchors only the start of the value. A pattern ending in shelf\.example also accepts https://admin.shelf.example.evil.example. End the pattern with $, escape the dots, fix the scheme, and test the look-alike origin as the example does.

  • The cross_origin decorator applies the same options to one view when the extension is not a good fit (@cross_origin(origins=[...]) below the route decorator); it also takes automatic_options. Use one mechanism per route: when both are applied, the extension notes that the request was already evaluated and the decorator does not repeat the work.

  • resources patterns are regular expressions matched against the path after decoding. Release 6.0.0 changed matching (case-sensitive paths, unquote instead of unquote_plus, and most-specific patterns first, an ordering corrected again in 6.0.1); code that relied on the old ordering must be re-tested.

  • Flask-CORS is a WSGI-level helper for Flask. It is not an authentication or CSRF mechanism, and it does not stop a disallowed origin’s request from being executed: Flask still processes it, and the browser only hides the response.

Credentials and the Wildcard Trap

A cross-origin request sends no cookies unless the script says so (credentials: "include" in fetch, withCredentials in XHR), and the browser then insists on an explicit origin and on Access-Control-Allow-Credentials: true. Flask-CORS enables the header with supports_credentials=True. The trap is the combination with the default origins="*":

from flask import Flask
from flask_cors import CORS

trap = Flask(__name__)
CORS(trap, supports_credentials=True)  # origins defaults to "*"


@trap.get("/api/me")
def me():
    return {"user": "ada"}


evil = trap.test_client().get("/api/me", headers={"Origin": "https://evil.example"})
# The extension echoes ANY origin and allows credentials: every site on the internet may now read
# the logged-in user's responses. Never ship this combination.
assert evil.headers["Access-Control-Allow-Origin"] == "https://evil.example"
assert evil.headers["Access-Control-Allow-Credentials"] == "true"

safe = Flask(__name__)
CORS(safe, origins=["https://shelf.example"], supports_credentials=True)
safe.get("/api/me")(lambda: {"user": "ada"})
stranger = safe.test_client().get("/api/me", headers={"Origin": "https://evil.example"})
assert "Access-Control-Allow-Origin" not in stranger.headers

Rules that keep credentialed CORS safe:

  • List the origins. With credentials, origins must be an explicit list (or a strictly anchored regular expression), never the default and never an origin read from the request and echoed back without a check.

  • Cookies and SameSite. A cookie sent by a cross-site request needs SameSite=None; Secure (HTTPS only), and browsers are restricting third-party cookies, so a SPA on app.example.com and an API on api.example.com (same site, different origins) is far more reliable than two unrelated domains. Flask’s SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE and the partitioned-cookie option SESSION_COOKIE_PARTITIONED are described in The SESSION_COOKIE_* Options. The proxy layouts avoid all of this.

  • CORS is not CSRF protection. A cross-origin form or simple request that is denied for reading is still sent with the victim’s cookies (see Cookie Authentication and CSRF).

  • Do not put secrets behind Access-Control-Allow-Origin: *. Without credentials the wildcard is fine for truly public data, such as the catalogue of public books, and send_wildcard=True makes that explicit.

  • Cache correctly. With several allowed origins the answer varies by Origin; leave vary_header on (it is by default) and make sure a CDN honours Vary.

The Preflight Must Bypass Authentication

The browser sends the preflight without cookies and without the Authorization header, because the preflight exists to ask permission before any credential is sent. An authentication check that runs for every request, such as a global before_request function that returns 401 for a request with no credentials, therefore rejects the preflight, and the browser reports a CORS failure ("response to preflight request doesn’t pass access control check") for what is really an authentication rule. This is the failure that the book Microservice APIs calls out in its authentication chapter, where the authentication middleware must let the OPTIONS request through. The following example reproduces both behaviours with the same hook.

from flask import Flask, jsonify, request
from flask_cors import CORS

ORIGIN = {"Origin": "http://localhost:5173"}
PREFLIGHT = {
    **ORIGIN,
    "Access-Control-Request-Method": "GET",
    "Access-Control-Request-Headers": "authorization",
}


def make_client(skip_options):
    app = Flask(__name__)
    CORS(app, origins=["http://localhost:5173"])

    @app.before_request
    def require_auth():
        if skip_options and request.method == "OPTIONS":
            return None  # the preflight carries no credentials by design
        if not request.headers.get("Authorization", "").startswith("Bearer "):
            return jsonify(error="unauthorized"), 401
        return None

    @app.get("/api/me")
    def me():
        return jsonify(user="ada")

    return app.test_client()


broken = make_client(skip_options=False).options("/api/me", headers=PREFLIGHT)
fixed = make_client(skip_options=True).options("/api/me", headers=PREFLIGHT)
assert broken.status_code == 401  # the browser needs a 2xx preflight, so the real request is never sent
assert fixed.status_code == 200 and fixed.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"

# The exemption is only for OPTIONS: the real request is still authenticated.
real = make_client(skip_options=True).get("/api/me", headers=ORIGIN)
assert real.status_code == 401
assert real.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"  # 401 stays readable by the SPA

Three things follow:

  • The order of the hooks decides the outcome. before_request functions run before the view, so a 401 returned there ends the request, and the extension’s after_request function still adds the CORS headers to that 401. This is deliberate: a page that is not allowed to read the 401 only sees an opaque network error and cannot tell the user to log in again. The preflight itself, however, must be a success; the headers on a 401 do not help.

  • Exempt OPTIONS and nothing else. Do not skip authentication for every path under /api/ or for any request that has an Origin header, because Origin is also sent by an attacker’s script.

  • Authentication helpers already do this. Flask-Login’s login_required does nothing for OPTIONS requests, and Flask-JWT-Extended’s jwt_required skips them too (its configuration lists OPTIONS as an exempt method, which the source of the installed package confirms). Hand-written decorators and middleware, including the ones in the book, must add the check themselves. See Authentication and Flask-Login for the Flask-Login rules and Hooks and View Decorators for the ordering of the hooks.

When the SPA and the API share an origin (or a site), Flask’s signed session cookie is the simplest login: no tokens in JavaScript, HttpOnly keeps the cookie out of reach of scripts (and of XSS), and SameSite=Lax stops it being attached to cross-site POST requests. The cost is CSRF: the browser attaches the cookie to any request to the site, including those started by another site, so state-changing endpoints need a token that only the SPA can read. Flask-WTF’s CSRFProtect checks the token of every unsafe request, and accepts it from the X-CSRFToken (or X-CSRF-Token) header, which is what a fetch call sets. The SPA fetches the token from a small endpoint and sends it back with each unsafe request.

import secrets

from flask import Flask, jsonify, request, session
from flask_cors import CORS
from flask_wtf.csrf import CSRFError, CSRFProtect, generate_csrf
from werkzeug.security import check_password_hash, generate_password_hash

PASSWORD = secrets.token_urlsafe(12)  # demo credential, generated for this run only
USERS = {"ada": generate_password_hash(PASSWORD)}

app = Flask(__name__)
app.config.update(
    SECRET_KEY=secrets.token_hex(32),  # load it from the environment or the instance folder in a real project
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    SESSION_COOKIE_SECURE=False,  # True behind HTTPS
)
CSRFProtect(app)
CORS(
    app,
    resources={r"/api/*": {
        "origins": ["http://localhost:5173"],
        "supports_credentials": True,
        "allow_headers": ["Content-Type", "X-CSRFToken"],
    }},
)


@app.errorhandler(CSRFError)
def csrf_failed(error):
    return jsonify(error="csrf", detail=error.description), 400


@app.get("/api/csrf")
def csrf_token():
    return jsonify(csrf_token=generate_csrf())


@app.post("/api/login")
def login():
    body = request.get_json()
    if not check_password_hash(USERS.get(body.get("user"), ""), body.get("password", "")):
        return jsonify(error="bad credentials"), 401
    session.clear()  # new session on login; this also drops the old CSRF token
    session["user"] = body["user"]
    return jsonify(user=body["user"])


@app.delete("/api/books/<int:book_id>")
def delete_book(book_id):
    if "user" not in session:
        return jsonify(error="login required"), 401
    return "", 204


client = app.test_client()
origin = {"Origin": "http://localhost:5173"}

token = client.get("/api/csrf", headers=origin).json["csrf_token"]
assert client.post("/api/login", json={"user": "ada", "password": PASSWORD}, headers={**origin, "X-CSRFToken": token}).status_code == 200

# Without a token the cookie alone is not enough.
assert client.delete("/api/books/1", headers=origin).status_code == 400
# The token fetched before the login belongs to the old session, so ask for a new one after login.
assert client.delete("/api/books/1", headers={**origin, "X-CSRFToken": token}).status_code == 400
fresh = client.get("/api/csrf", headers=origin).json["csrf_token"]
done = client.delete("/api/books/1", headers={**origin, "X-CSRFToken": fresh})
assert done.status_code == 204 and done.headers["Access-Control-Allow-Credentials"] == "true"
cookie = client.get_cookie("session")
assert cookie.http_only and cookie.same_site == "Lax"

Notes on the example:

  • The demo password is generated at run time with secrets.token_urlsafe and only its hash is stored. Real passwords come from a user table (see Authentication and Flask-Login), and the key comes from the environment (see Configuration).

  • session.clear() on login prevents session fixation, and, as the assertions show, it also invalidates the CSRF token that was stored in the old session; a SPA that caches the token must fetch a new one after login, or the first state-changing call fails with 400. Return the new token from the login response to save a round trip.

  • CSRFError is a 400 with an HTML body by default; the handler above turns it into JSON for the SPA. The X-CSRFToken header is a custom header, so it triggers a preflight, and allow_headers must list it, otherwise the browser blocks the request before Flask sees it.

  • Flask-WTF additionally checks Referer against the host for HTTPS requests (WTF_CSRF_SSL_STRICT), which can produce surprises behind a proxy that rewrites the host; the CSRF rules of the whole application are in Cross-Site Request Forgery (CSRF) and the form options in Forms with Flask-WTF.

  • The Flask session is signed, not encrypted: the SPA could decode its contents. Do not put anything in it that the user must not read. The book Full-Stack Flask and React describes the cookie as encrypted, which is wrong for the default session (see How the Signed Cookie Works, and Why Signed Is Not Encrypted).

Token Authentication for SPAs

The alternative is a bearer token: the SPA posts the credentials, receives a signed token (a JWT, in this ecosystem usually through Flask-JWT-Extended) and sends it in an Authorization: Bearer <token> header. Tokens suit mobile clients and APIs that third parties call, they are not attached automatically by the browser (so a plain CSRF attack is impossible), and they work across origins without the cookie rules. They bring their own work: revocation, expiry, refresh, and the question of where the SPA keeps the token. The header is not a CORS-safelisted header, so the browser always preflights the request, and the allow-list must name it.

import secrets

from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_jwt_extended import JWTManager, create_access_token, get_jwt_identity, jwt_required
from werkzeug.security import check_password_hash, generate_password_hash

PASSWORD = secrets.token_urlsafe(12)
USERS = {"ada": {"id": "1", "hash": generate_password_hash(PASSWORD)}}

tokens = Flask(__name__)
tokens.config["JWT_SECRET_KEY"] = secrets.token_hex(32)  # read it from the environment in a real project
JWTManager(tokens)
CORS(
    tokens,
    resources={r"/api/*": {
        "origins": ["http://localhost:5173"],
        "allow_headers": ["Authorization", "Content-Type"],
        "max_age": 600,
    }},
)


@tokens.post("/api/login")
def token_login():
    body = request.get_json()
    user = USERS.get(body.get("user"))
    if user is None or not check_password_hash(user["hash"], body.get("password", "")):
        return jsonify(error="bad credentials"), 401
    return jsonify(access_token=create_access_token(identity=user["id"]))  # the identity must be a string


@tokens.get("/api/me")
@jwt_required()  # the parentheses are required in Flask-JWT-Extended 4.x
def token_me():
    return jsonify(user_id=get_jwt_identity())


c = tokens.test_client()
o = {"Origin": "http://localhost:5173"}

pre = c.options("/api/me", headers={**o, "Access-Control-Request-Method": "GET", "Access-Control-Request-Headers": "authorization"})
assert pre.status_code == 200 and pre.headers["Access-Control-Allow-Headers"] == "authorization"  # jwt_required skips OPTIONS

access = c.post("/api/login", json={"user": "ada", "password": PASSWORD}, headers=o).json["access_token"]
ok = c.get("/api/me", headers={**o, "Authorization": "Bearer " + access})
assert ok.status_code == 200 and ok.json == {"user_id": "1"}
assert ok.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
assert c.get("/api/me", headers=o).status_code == 401

The full treatment of tokens (claims, expiry and refresh, revocation, asymmetric keys, validating aud and the algorithm) belongs to the page about API authentication with JWT in this section; the points that concern a SPA are:

  • Where to keep the token. A token in localStorage or sessionStorage is readable by every script on the page, so one XSS flaw leaks it for its whole lifetime. Keeping it in a JavaScript variable (memory) reduces the exposure but is lost on reload. The book Full-Stack Flask and React stores the token in localStorage without discussing that risk. The usual compromise is a short-lived access token in memory and a refresh token in an HttpOnly, Secure, SameSite cookie, which is then a cookie that needs the CSRF protection above, scoped to the refresh path.

  • JWT in cookies. Flask-JWT-Extended can carry the token in cookies instead (JWT_TOKEN_LOCATION = ["cookies"]). Then the access cookie is HttpOnly, and a second, readable cookie holds a CSRF token that the SPA copies into the X-CSRF-TOKEN header (the double-submit pattern); the request fails with 401 without it, and the allow_headers list must name it:

import secrets

from flask import Flask, jsonify
from flask_cors import CORS
from flask_jwt_extended import JWTManager, create_access_token, get_jwt_identity, jwt_required, set_access_cookies

cookie_jwt = Flask(__name__)
cookie_jwt.config.update(
    JWT_SECRET_KEY=secrets.token_hex(32),
    JWT_TOKEN_LOCATION=["cookies"],
    JWT_COOKIE_SECURE=False,  # True behind HTTPS
    JWT_COOKIE_SAMESITE="Lax",
    JWT_COOKIE_CSRF_PROTECT=True,
)
JWTManager(cookie_jwt)
CORS(
    cookie_jwt,
    resources={r"/api/*": {
        "origins": ["http://localhost:5173"],
        "supports_credentials": True,
        "allow_headers": ["Content-Type", "X-CSRF-TOKEN"],
    }},
)


@cookie_jwt.post("/api/login")
def cookie_login():
    response = jsonify(login=True)
    set_access_cookies(response, create_access_token(identity="1"))
    return response


@cookie_jwt.post("/api/books")
@jwt_required()
def cookie_add_book():
    return jsonify(created_by=get_jwt_identity()), 201


c2 = cookie_jwt.test_client()
o2 = {"Origin": "http://localhost:5173"}
assert c2.post("/api/login", headers=o2).status_code == 200
assert c2.post("/api/books", headers=o2).status_code == 401  # cookie present, double-submit header missing
csrf_cookie = c2.get_cookie("csrf_access_token")
assert not csrf_cookie.http_only and c2.get_cookie("access_token_cookie").http_only
assert c2.post("/api/books", headers={**o2, "X-CSRF-TOKEN": csrf_cookie.value}).status_code == 201
Concern Cookie session (or JWT in cookies) Bearer token in a header

Sent automatically by the browser

Yes: needs CSRF protection and a SameSite choice.

No: no CSRF, but the script must attach it and a preflight happens on every cross-origin call.

Readable by injected scripts (XSS)

Not with HttpOnly. The attacker can still make requests as the user from the page.

Yes if stored in web storage: the token can be stolen and used elsewhere.

Cross-origin use

Needs supports_credentials, an explicit origin, credentials: "include" and SameSite=None; Secure across sites.

Works with any origin that CORS allows; no credentials mode required.

Revocation and logout

Server-side sessions revoke immediately; signed cookies cannot be revoked before expiry.

A short expiry plus a deny-list or refresh-token rotation.

Natural fit

A SPA served from the same site as the API.

Mobile clients, third-party API consumers, separately hosted SPAs.

The pragmatic default for the Bookshelf: serve the SPA and the API from the same site (the proxy layout), use the signed session cookie with SameSite=Lax and the CSRF header, and add bearer tokens only for clients that are not browsers.

Integrating React with Flask

The React side is small. Three fetch calls cover the patterns of this page: a call with the same origin (the proxy or the served build), a cross-origin call with a bearer token, and a cross-origin call with the session cookie and the CSRF header. Where the API lives is a build-time variable, so the same code runs in all environments.

// src/api.js
const API = import.meta.env.VITE_API_URL ?? ""; // "" = same origin (proxy or Flask-served build)

let csrfToken = null;

async function csrf() {
  if (csrfToken === null) {
    const res = await fetch(`${API}/api/csrf`, { credentials: "include" });
    csrfToken = (await res.json()).csrf_token;
  }
  return csrfToken;
}

export async function listBooks() {
  const res = await fetch(`${API}/api/books`, { credentials: "include" });
  if (!res.ok) throw new Error(`API error ${res.status}`);
  return res.json();
}

export async function addBook(book) {
  const res = await fetch(`${API}/api/books`, {
    method: "POST",
    credentials: "include", // send the session cookie (cross-origin: needs CORS supports_credentials)
    headers: { "Content-Type": "application/json", "X-CSRFToken": await csrf() },
    body: JSON.stringify(book),
  });
  if (res.status === 400) csrfToken = null; // the session changed: fetch a new token on the next call
  if (!res.ok) throw new Error(`API error ${res.status}`);
  return res.json();
}

export async function listBooksWithBearer(token) {
  // The Authorization header is not CORS-safelisted: the browser sends a preflight first.
  return (await fetch(`${API}/api/books`, { headers: { Authorization: `Bearer ${token}` } })).json();
}

VITE_API_URL is read by Vite from .env files and is exposed to the page only when the name starts with VITE_; do not put secrets in such variables, because the browser receives them. fetch by default sends cookies only to the same origin, so credentials: "include" is needed for the cross-origin case and harmless for the same-origin one. A fetch that fails with a TypeError and no status is a network or CORS failure, so open the browser’s network tab: a preflight answered with 401, 403 or 405 (typically by a before-request hook, see The Preflight Must Bypass Authentication) is the first thing to look for.

Posting a classic HTML form (no JavaScript) from a different origin, as the book does to show that CORS applies, works without CORS, because form submissions are not subject to it (the browser navigates to the response); only a script’s fetch or XHR needs the permission. State-changing form posts are still exactly what CSRF protection exists for. For data fetching patterns on the React side (effects, caching libraries, error states) and for deployment of the build, see Data Fetching in React and React Build and Deployment.

Testing the CORS Contract

Because the failures show up only in a browser, put the contract into the test suite: a test per allowed origin, one for a stranger, and one for each preflight the SPA triggers. The Flask test client lets you build these requests directly (see The Application and Request Lifecycle for what runs per request).

from flask import Flask, jsonify
from flask_cors import CORS


def make_cors_client():
    flask_app = Flask(__name__)
    CORS(flask_app, resources={r"/api/*": {"origins": ["http://localhost:5173"], "allow_headers": ["Content-Type"]}})

    @flask_app.get("/api/books")
    def list_books():
        return jsonify([])

    @flask_app.post("/api/books")
    def add_book():
        return jsonify(id=1), 201

    return flask_app.test_client()


# With pytest these are test functions that receive make_cors_client() as a fixture.
def test_preflight_of_json_post(cors_client):
    response = cors_client.options(
        "/api/books",
        headers={
            "Origin": "http://localhost:5173",
            "Access-Control-Request-Method": "POST",
            "Access-Control-Request-Headers": "content-type",
        },
    )
    assert response.status_code == 200  # the browser needs a 2xx
    assert response.headers["Access-Control-Allow-Origin"] == "http://localhost:5173"
    assert "POST" in response.headers["Access-Control-Allow-Methods"]


def test_stranger_gets_no_grant(cors_client):
    response = cors_client.get("/api/books", headers={"Origin": "https://evil.example"})
    assert "Access-Control-Allow-Origin" not in response.headers


test_preflight_of_json_post(make_cors_client())
test_stranger_gets_no_grant(make_cors_client())

Pitfalls and Security Checklist

Pitfall What to do

CORS(app) with no arguments

It opens every route to every origin. Restrict resources to /api/* and origins to the front end.

supports_credentials=True with the default origins="*"

The extension echoes any origin, so any site can read logged-in responses. Always list the origins.

A global authentication hook that rejects OPTIONS

The preflight fails and the browser reports a CORS error. Exempt OPTIONS (only), or use decorators that already do.

Origin lists with the wrong form

localhost:5173 is not an origin. Write the scheme and port (http://localhost:5173); an https front end and an http one are different origins.

Unanchored origin regular expressions

.*shelf.example matches https://shelf.example.evil.example. Fix the scheme, escape dots and test the negative case.

Treating CORS as access control

CORS limits what browsers show; curl is unaffected and the request is still processed. Authenticate and authorize every endpoint on the server.

Reading CORS errors as server errors

Check the real status in the network tab. A 500 without CORS headers looks like a CORS failure; Flask-CORS adds the headers to error responses by default (CORS_INTERCEPT_EXCEPTIONS), but an error raised in a proxy or by the server before Flask runs does not get them.

History fallback returning 200 for API typos and missing assets

Fall back only for GET requests of paths that look like pages, as in the first example, and keep /api/ and file-like paths as 404.

Caching the shell

Serve index.html with revalidation and hashed assets as immutable, otherwise users run an old front end against a new API.

CSRF token lost after login or logout

session.clear() drops the token. Fetch a new one after each session change, and answer CSRFError with JSON the SPA understands.

Tokens in localStorage

One XSS flaw exposes them. Prefer HttpOnly cookies, or keep short-lived tokens in memory. Add a Content Security Policy (see Security Headers); a SPA needs connect-src to list the API origin.

Cross-site cookies in development

localhost:5173 and localhost:5000 are same-site, so SameSite=Lax cookies work there, but they do not between unrelated production domains. Test the production topology, not only the dev one.

Secrets in the front-end bundle

Anything in the JavaScript bundle is public. Keep keys on the server and let the SPA call the API.

The debugger or flask run on a public host

Neither belongs in production; see Never Run the Debugger in Production.

Recent Changes and Where the Books Are Outdated

Version Change Consequence

Create React App, deprecated February 2025

Full-Stack Flask and React builds its React project with it.

Use Vite (npm create vite@latest), with server.proxy for development and vite build for production.

Flask-CORS 3.0.0

always_send introduced, on by default.

Headers are sent even for requests without Origin; handy with curl, and expected by tests written for newer releases.

Flask-CORS 3.0.9 and 4.0.2

Path matching fixed so that /api/../foo no longer matches /api/*; private-network preflight handling added (allow_private_network).

Upgrade; older releases carry security advisories.

Flask-CORS 5.0.0

Private network access is off by default.

Pages that need access to private-network targets must set allow_private_network explicitly.

Flask-CORS 6.0.0 and 6.0.1

Resource paths are matched case-sensitively, with unquote instead of unquote_plus, most-specific pattern first (corrected in 6.0.1).

Re-test every resources dictionary after an upgrade from 5.x.

Flask-CORS 6.0.4 and 6.0.5

Typed options and py.typed; blueprint support in the type signatures restored in 6.0.5.

Prefer 6.0.5 or newer when type-checking CORS(blueprint).

Flask 2.3.2

Vary: Cookie is added when the session is accessed.

Caches no longer share session-dependent responses; verify the headers of cached API routes.

Flask 2.3

SESSION_COOKIE_DOMAIN no longer falls back to SERVER_NAME.

Set it explicitly when the SPA and API are on sibling sub-domains and must share the cookie.

Flask 3.1

SESSION_COOKIE_PARTITIONED (CHIPS).

A partitioned cookie is an option for embedded or cross-site front ends that need SameSite=None.

Flask-JWT-Extended 4.x

@jwt_required() needs parentheses; the identity should be a string with recent PyJWT.

The token example in the book Full-Stack Flask and React uses neither; copy the form on this page.

Flask 3.2 (unreleased)

No change to routing of OPTIONS or to the errors described here.

Nothing to do.

The book Microservice APIs documents the preflight rule in its authentication chapter, and that rule has not changed: the preflight still carries no credentials. The Full-Stack Flask and React book enables CORS with the extension’s default options in a tutorial context, which, as shown in Credentials and the Wildcard Trap, should not be copied into a service that uses cookies. It also stores the login token in localStorage and describes the Flask session cookie as encrypted; both statements are corrected on this page and in the sessions page.