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.
| 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 |
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 |
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 |
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
Source: Flask: Single-Page Applications
What the example relies on, and what to adjust:
-
static_url_path="/"makes the static rule/<path:filename>. A rule with apathconverter never matches the bare/, so the root falls through to the404handler, which returns the shell. If the build is served under a prefix, give the same prefix to the front-end tool (Vite’sbaseoption) and tostatic_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.htmlmust be revalidated on every visit (theETagthe 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. TheSEND_FILE_MAX_AGE_DEFAULTsetting 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
GETrequests. APOSTto a page path is an error,405 Method Not Allowed, because the static rule/<path:filename>matches every path but only allowsGETandHEAD; the same rule is why aPOSTto/api/typois a405too, and why an API that wants JSON for every error also registers a405handler. -
Register the handler on the application, not on a blueprint: a blueprint’s error handlers are not consulted for routing
404errors, because no blueprint has matched yet (see Blueprints). -
Everything that reaches Flask passes through
before_requesthooks, 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: falseFlask seesHost: localhost:5173. That matters forurl_for(..., _external=True), redirects andTRUSTED_HOSTS. SetchangeOrigin: trueto send the target’s host instead, or configure Flask’sProxyFixwhen a production proxy suppliesX-Forwarded-*headers (see Host Header Validation and Proxies). -
Cookies. The session cookie is set for
localhostwithout a port, so it works through the proxy exactly as in production. Cookies are keyed by host, not by port, which is also whylocalhost:5173andlocalhost:5000are same-site although they are cross-origin: the distinction matters forSameSitebelow. -
WebSockets and streaming.
ws: truein 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 buildproduces 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:
-
Every cross-origin request carries an
Originheader 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. -
A simple request (
GET,HEADorPOSTwith only the headers a browser sets itself and a form content type) goes out directly; the response must containAccess-Control-Allow-Originnaming 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. -
Any other request (
PUT,PATCH,DELETE, a JSONContent-Type, anAuthorizationheader, a custom header such asX-CSRFToken) is preflighted. The browser first sendsOPTIONSwithOrigin,Access-Control-Request-MethodandAccess-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 anAuthorizationheader. -
A request made with credentials (
fetch(..., { credentials: "include" })) needsAccess-Control-Allow-Credentials: trueand an explicit origin: the wildcard*is refused by the browser for credentialed requests.
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"
Source: Flask API: Flask.after_request
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", "")
Source: Flask-CORS: API Docs
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 |
|---|---|---|
|
|
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 ( |
|
|
Written to |
|
|
Request headers a page may send. Only the headers named in |
|
|
Response headers that page scripts may read ( |
|
|
Adds |
|
|
Seconds a browser may cache a preflight answer ( |
|
|
With |
|
|
Adds |
|
|
Only for the |
|
|
Send CORS headers even when the request has no |
|
|
Wrap Flask’s exception handling so error responses carry the headers. |
|
|
Answer the private-network-access preflight header ( |
|
|
Path patterns (regular expressions) that receive CORS handling, as a string, a list, or a dictionary of pattern to options. Most-specific patterns win. |
Source: Flask-CORS: Configuration
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"]
Source: Flask-CORS: Configuration
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 inshelf\.examplealso acceptshttps://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_origindecorator applies the same options to one view when the extension is not a good fit (@cross_origin(origins=[...])below the route decorator); it also takesautomatic_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. -
resourcespatterns are regular expressions matched against the path after decoding. Release 6.0.0 changed matching (case-sensitive paths,unquoteinstead ofunquote_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,
originsmust 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 needsSameSite=None; Secure(HTTPS only), and browsers are restricting third-party cookies, so a SPA onapp.example.comand an API onapi.example.com(same site, different origins) is far more reliable than two unrelated domains. Flask’sSESSION_COOKIE_SAMESITE,SESSION_COOKIE_SECUREand the partitioned-cookie optionSESSION_COOKIE_PARTITIONEDare 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, andsend_wildcard=Truemakes that explicit. -
Cache correctly. With several allowed origins the answer varies by
Origin; leavevary_headeron (it is by default) and make sure a CDN honoursVary.
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
Source: Flask API: Flask.before_request
Three things follow:
-
The order of the hooks decides the outcome.
before_requestfunctions run before the view, so a401returned there ends the request, and the extension’safter_requestfunction still adds the CORS headers to that401. This is deliberate: a page that is not allowed to read the401only 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 a401do not help. -
Exempt
OPTIONSand nothing else. Do not skip authentication for every path under/api/or for any request that has anOriginheader, becauseOriginis also sent by an attacker’s script. -
Authentication helpers already do this. Flask-Login’s
login_requireddoes nothing forOPTIONSrequests, and Flask-JWT-Extended’sjwt_requiredskips them too (its configuration listsOPTIONSas 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.
Cookie Authentication and CSRF
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"
Source: Flask-WTF: CSRF Protection
Notes on the example:
-
The demo password is generated at run time with
secrets.token_urlsafeand 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 with400. Return the new token from the login response to save a round trip. -
CSRFErroris a400with an HTML body by default; the handler above turns it into JSON for the SPA. TheX-CSRFTokenheader is a custom header, so it triggers a preflight, andallow_headersmust list it, otherwise the browser blocks the request before Flask sees it. -
Flask-WTF additionally checks
Refereragainst 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
Source: Flask-JWT-Extended: Basic Usage
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
localStorageorsessionStorageis 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 inlocalStoragewithout discussing that risk. The usual compromise is a short-lived access token in memory and a refresh token in anHttpOnly,Secure,SameSitecookie, 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 isHttpOnly, and a second, readable cookie holds a CSRF token that the SPA copies into theX-CSRF-TOKENheader (the double-submit pattern); the request fails with401without it, and theallow_headerslist 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 |
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 |
Yes if stored in web storage: the token can be stolen and used elsewhere. |
Cross-origin use |
Needs |
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 |
|---|---|
|
It opens every route to every origin. Restrict |
|
The extension echoes any origin, so any site can read logged-in responses. Always list the origins. |
A global authentication hook that rejects |
The preflight fails and the browser reports a CORS error. Exempt |
Origin lists with the wrong form |
|
Unanchored origin regular expressions |
|
Treating CORS as access control |
CORS limits what browsers show; |
Reading CORS errors as server errors |
Check the real status in the network tab. A |
History fallback returning |
Fall back only for |
Caching the shell |
Serve |
CSRF token lost after login or logout |
|
Tokens in |
One XSS flaw exposes them. Prefer |
Cross-site cookies in development |
|
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 |
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 ( |
Flask-CORS 3.0.0 |
|
Headers are sent even for requests without |
Flask-CORS 3.0.9 and 4.0.2 |
Path matching fixed so that |
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 |
Flask-CORS 6.0.0 and 6.0.1 |
Resource paths are matched case-sensitively, with |
Re-test every |
Flask-CORS 6.0.4 and 6.0.5 |
Typed options and |
Prefer 6.0.5 or newer when type-checking |
Flask 2.3.2 |
|
Caches no longer share session-dependent responses; verify the headers of cached API routes. |
Flask 2.3 |
|
Set it explicitly when the SPA and API are on sibling sub-domains and must share the cookie. |
Flask 3.1 |
|
A partitioned cookie is an option for embedded or cross-site front ends that need |
Flask-JWT-Extended 4.x |
|
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 |
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.