Static Files & Assets

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 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 static file handling. The page follows an asset from the disk to the browser: the built-in static route and its options, how to build URLs and bust caches, how Flask decides on cache headers, how to serve other files safely, the favicon, what changes in production (nginx, a CDN, X-Sendfile), a front-end build with Vite, and finally the JavaScript, fetch and JSON pattern that connects a static script to the Bookshelf API.

How Flask Serves Static Files

A Flask application with a static folder next to its code serves that folder with no further work. When the Flask object is created, and its static_folder is not None, the constructor registers one URL rule, <static_url_path>/<path:filename>, with the endpoint static. The view behind it is Flask.send_static_file(), which calls send_from_directory() on the static folder. Werkzeug does the file work (werkzeug.utils.send_from_directory, safe_join and send_file: MIME type, ETag, Last-Modified, conditional and range requests); Flask only decides the folder, the URL and the default cache lifetime.

Two lanes. In development the browser asks the Flask static route, which reads the static folder. In production the browser asks an optional CDN, then nginx with a location for /static/, which reads the built assets; every other request goes from nginx to Gunicorn and the Flask workers.
Figure 1. Development: Flask answers. Production: the proxy answers, and Python is left for dynamic requests.

Flask’s own static route is meant for development and small deployments. It is correct, but it occupies a worker for every image, and a reverse proxy serves a file faster and with less memory (see production). Two things follow from the design and are worth keeping in mind from the start. The static route is an ordinary Flask route, so before_request functions, url_value_preprocessor functions and after_request functions run for it too, which is why an authentication hook that rejects every request without a session also rejects the stylesheet of the login page. And the route is only registered when the application is created, so static_folder must be given to the constructor (or to the Blueprint), not changed later.

static_folder, static_url_path and static_host

The three constructor arguments are shared by Flask and Blueprint (the application-factory page lists them in context; the blueprint rules are in Blueprints).

Argument Default Meaning

static_folder

"static"

Folder served by the static route, relative to the application root_path or an absolute path. None disables the route and the static endpoint. A Blueprint has no static folder unless it is given one.

static_url_path

/ followed by the last part of static_folder

URL prefix of the route. The default is derived from the folder name, so static_folder="assets" serves /assets/....

static_host

None

Host used when the static rule is registered. It requires host_matching=True on the application (the constructor asserts that static_host and host_matching agree) and a matching url_for call.

The properties app.static_folder (an absolute path), app.static_url_path and app.has_static_folder expose the resolved values. The first example creates a throwaway folder tree for the Bookshelf and shows the defaults, a renamed folder and a disabled route.

import pathlib
import tempfile

from flask import Flask, url_for

work = pathlib.Path(tempfile.mkdtemp(prefix="bookshelf-static-"))


def write(rel, text):
    path = work / rel
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text)
    return path


write("static/css/site.css", "body { font-family: sans-serif; }\n")
write("static/js/books.js", "console.log('bookshelf');\n")
write("static/favicon.ico", "not-really-an-icon")
write("assets/logo.svg", "<svg xmlns='http://www.w3.org/2000/svg'/>")
write("secret.txt", "not for the web")

app = Flask("bookshelf", root_path=str(work))
assert app.static_folder == str(work / "static")
assert app.static_url_path == "/static"
assert app.has_static_folder

renamed = Flask("renamed", root_path=str(work), static_folder="assets")
assert renamed.static_url_path == "/assets"

custom = Flask("custom", root_path=str(work), static_folder="assets", static_url_path="/theme")
with custom.test_request_context():
    assert url_for("static", filename="logo.svg") == "/theme/logo.svg"

nostatic = Flask("nostatic", root_path=str(work), static_folder=None)
assert not nostatic.has_static_folder
assert "static" not in {rule.endpoint for rule in nostatic.url_map.iter_rules()}
assert nostatic.test_client().get("/static/css/site.css").status_code == 404

static_url_path may be "", which mounts the folder at the root of the site (/site.css). That is convenient for a folder that only holds robots.txt and the like, but the static rule then competes with the application’s own routes. Werkzeug sorts rules by specificity, so a fixed route such as /books still wins over <path:filename>; use a distinct prefix unless there is a reason not to.

Building URLs with url_for("static", ...)

Never write /static/css/site.css in a template. url_for("static", filename="css/site.css") follows the application’s static_url_path, the SCRIPT_NAME of a deployment under a sub-path (see Routing & URL Building for url_for in general) and any prefix added by a blueprint. The same function builds absolute URLs with _external=True.

from flask import render_template_string

app = Flask("bookshelf", root_path=str(work))
client = app.test_client()


@app.get("/")
def home():
    return render_template_string(
        '<link rel="stylesheet" href="{{ url_for(\'static\', filename=\'css/site.css\') }}">'
        '<script src="{{ url_for(\'static\', filename=\'js/books.js\') }}" defer></script>'
    )


with app.test_request_context():
    assert url_for("static", filename="css/site.css") == "/static/css/site.css"
    assert url_for("static", filename="css/site.css", _external=True) == "http://localhost/static/css/site.css"

html = client.get("/").get_data(as_text=True)
assert 'href="/static/css/site.css"' in html and 'src="/static/js/books.js"' in html

with client.get("/static/css/site.css") as response:
    assert response.status_code == 200
    assert response.mimetype == "text/css"
    assert response.get_data(as_text=True).startswith("body")

Extra keyword arguments to url_for that are not part of the rule become the query string, which is the hook for cache busting in the next section. A call to url_for("static", ...) in an application whose static_folder is None raises BuildError, and a call to app.send_static_file() raises RuntimeError ("'static_folder' must be set to serve static_files").

Cache Busting

Browsers keep a stylesheet for as long as the response says they may. The static route can therefore leave users with a stale site.css after a deployment, unless the URL changes whenever the content does. There are two designs.

A version in the query string keeps the file names and appends something that changes with the content. It needs no build step, and Flask has a hook that lets every url_for("static", ...) call add it without touching the templates: @app.url_defaults receives the endpoint and the values and may modify the values in place. The sketch below uses the modification time of the file; a content hash is stronger (identical content gives the same URL after a redeploy) and is cheap to compute once and remember.

import hashlib
import os
from functools import lru_cache


@lru_cache(maxsize=512)
def _digest(path: str, mtime_ns: int) -> str:
    # The mtime is part of the cache key: a changed file is hashed again once.
    with open(path, "rb") as handle:
        return hashlib.sha256(handle.read()).hexdigest()[:10]


app = Flask("bookshelf", root_path=str(work))


@app.url_defaults
def add_static_version(endpoint, values):
    if endpoint != "static" or "filename" not in values:
        return
    path = os.path.join(app.static_folder, values["filename"])
    try:
        values.setdefault("v", _digest(path, os.stat(path).st_mtime_ns))
    except OSError:
        pass  # unknown file: leave the URL alone and let the route answer 404


with app.test_request_context():
    versioned = url_for("static", filename="css/site.css")
    assert versioned.startswith("/static/css/site.css?v=") and len(versioned.split("=")[1]) == 10
    assert url_for("static", filename="missing.css") == "/static/missing.css"

before = versioned
write("static/css/site.css", "body { font-family: serif; }\n")
with app.test_request_context():
    assert url_for("static", filename="css/site.css") != before

The function only handles the application’s own static endpoint. A blueprint’s static endpoint is called <blueprint>.static, so supporting it means looking up the blueprint’s static_folder. url_for also needs the app global of the example, which is why a factory would write the hook inside create_app().

A hash in the file name (site.3f9a1c2b.css) is what bundlers produce. The URL is unique to the content, so the file can be cached for a year and never revalidated; the application only has to know the mapping from the source name to the built name, which is the topic of the Vite section. File-name hashing beats the query string in one practical way: some CDNs and proxies ignore the query string when they compute the cache key, and then a ?v= change does nothing.

Cache Headers: SEND_FILE_MAX_AGE_DEFAULT

The static view and send_file answer every request with a validator (ETag and Last-Modified) and handle conditional requests, so a browser that already holds the file receives an empty 304 Not Modified. What Flask puts in Cache-Control depends on max_age:

  • with no max_age and SEND_FILE_MAX_AGE_DEFAULT = None (the default), the response says Cache-Control: no-cache. That does not forbid storing the file; it makes the browser revalidate before reuse, which is cheap and always correct;

  • with a number of seconds (or a datetime.timedelta), the response gets Cache-Control: public, max-age=N and an Expires header, and the browser reuses the file without asking until it expires.

Since Flask 2.0 the default is None. Older code and the books that predate it describe a 12-hour default; if you see a 12-hour max-age in an old deployment, someone set it. The method that turns the configuration into a number is Flask.get_send_file_max_age(filename), and send_file and the static view both call it, so a subclass can choose the lifetime per file. A long lifetime is only safe for URLs that change with the content, so the example below makes versioned URLs immutable and keeps everything else on revalidation.

from datetime import timedelta

from flask import request


class BookshelfApp(Flask):
    def get_send_file_max_age(self, filename):
        # The ?v= query comes from add_static_version above: such URLs change with the content.
        if request and "v" in request.args:
            return int(timedelta(days=365).total_seconds())
        return super().get_send_file_max_age(filename)


app = BookshelfApp("bookshelf", root_path=str(work))
app.url_defaults(add_static_version)
client = app.test_client()

with client.get("/static/css/site.css") as plain:
    assert plain.headers["Cache-Control"] == "no-cache"
    etag = plain.headers["ETag"]
    assert "Last-Modified" in plain.headers

with client.get("/static/css/site.css", headers={"If-None-Match": etag}) as conditional:
    assert conditional.status_code == 304

with client.get("/static/css/site.css?v=abc") as versioned_response:
    assert versioned_response.cache_control.max_age == 31536000
    assert versioned_response.cache_control.public

# The configuration key is the blunt alternative: one lifetime for every static file.
fixed = Flask("fixed", root_path=str(work))
fixed.config["SEND_FILE_MAX_AGE_DEFAULT"] = timedelta(hours=1)
with fixed.test_client().get("/static/css/site.css") as hourly:
    assert hourly.cache_control.max_age == 3600

The immutable cache directive (RFC 8246) tells a browser not to revalidate on a reload either. Werkzeug’s response cache_control object has an immutable property, so an after_request function can add it to versioned static responses. Browsers also revalidate on a hard reload regardless of max-age, and none of this applies to the HTML pages, which should keep revalidating so that they can name the new asset URLs.

Other Files: send_from_directory and Path Safety

The static folder is for files that belong to the application. Files the application stores, such as the book covers in the File Uploads page, belong in a folder that is not under static/, and are served by a view that calls send_from_directory(directory, path). The function joins the client’s path to the trusted directory with werkzeug.security.safe_join, answers 404 when the result escapes the directory or is not a regular file, and otherwise calls send_file, with the same validators and max_age handling as above. The directory must never come from the client.

from flask import send_from_directory
from werkzeug.exceptions import NotFound
from werkzeug.security import safe_join

write("media/covers/dune.txt", "cover bytes")
MEDIA = work / "media"

app = Flask("bookshelf", root_path=str(work))
client = app.test_client()


@app.get("/covers/<path:name>")
def cover(name):
    return send_from_directory(MEDIA / "covers", name, max_age=3600)


assert client.get("/covers/dune.txt").status_code == 200
assert client.get("/covers/missing.txt").status_code == 404
assert client.get("/covers/../../secret.txt").status_code == 404
assert client.get("/covers/..%2f..%2fsecret.txt").status_code == 404
assert client.get("/static/..%2fsecret.txt").status_code == 404

# the rule behind the 404 ...
assert safe_join(str(MEDIA), "covers/dune.txt") == str(MEDIA / "covers" / "dune.txt")
assert safe_join(str(MEDIA), "../secret.txt") is None
# ... and the exception the view helper raises
with app.test_request_context():
    try:
        send_from_directory(MEDIA, "../secret.txt")
    except NotFound:
        pass
    else:
        raise AssertionError("path traversal was not rejected")

Use send_file only with paths that the application built itself: its documentation says never to pass a path from the user. The renamed arguments of send_file (download_name, max_age, etag) and the path argument of send_from_directory are described on Responses & JSON; the receiving side of uploads, with secure_filename and size limits, is in the page about file uploads of this section.

Note that safe_join does not look at symlinks. A symlink inside the folder that points outside it is followed, so do not put such links in a served folder. Since Werkzeug 3.1.9 safe_join also rejects special Windows device names such as CON and NUL that would otherwise hang the read.

The Favicon

Browsers request /favicon.ico on their own unless the page names an icon. The cleanest answer is a <link> in the base template, which can point into the static folder and use any format and size:

<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" sizes="any">

For clients that ignore the link (feed readers, link previewers, some tools), answer the well-known path as well. Either redirect it to the static file with the redirect_to option that add_url_rule passes on to Werkzeug’s rule, or serve the bytes directly:

app = Flask("bookshelf", root_path=str(work))
app.add_url_rule("/favicon.ico", endpoint="favicon", redirect_to="/static/favicon.ico")



@app.get("/apple-touch-icon.png")
def touch_icon():
    return send_from_directory(app.static_folder, "favicon.ico", mimetype="image/png", max_age=86400)


response = app.test_client().get("/favicon.ico")
assert response.status_code in (301, 302, 307, 308)
assert response.headers["Location"].endswith("/static/favicon.ico")

with app.test_client().get("/apple-touch-icon.png") as icon:
    assert icon.mimetype == "image/png" and icon.cache_control.max_age == 86400

A missing favicon is a common source of noise: every page view then produces a 404 for /favicon.ico in the log. Tests that assert "no 404 in the logs" are better off adding the file than filtering the line.

Static Files in Production

In production the static route should not be the one answering. A reverse proxy such as nginx reads the files with the operating system’s file cache and sendfile, supports compression and long lifetimes with one line each, and leaves the Gunicorn workers free for the dynamic requests (the topology is on the figure at the top). Deployment itself, with the WSGI server, ProxyFix and HTTPS, is the subject of the page about deployment of this section; the static part is a location block.

server {
    listen 443 ssl;
    server_name bookshelf.example.org;

    # Files built by Vite: the names contain a content hash, so they never change.
    location /static/dist/ {
        alias /srv/bookshelf/static/dist/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Other static files: revalidate.
    location /static/ {
        alias /srv/bookshelf/static/;
        add_header Cache-Control "no-cache";
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The URL prefix in the location must equal the application’s static_url_path and the alias must point at the static folder of the deployed code, with a trailing slash on both (otherwise the path is concatenated wrongly). The add_header directives are not inherited into a block that defines its own, so repeat the security headers there.

Three variations deserve a mention:

  • X-Sendfile and X-Accel-Redirect. For files that need an authorisation check in Python but should be sent by the proxy, Flask can answer with an empty body and a header that tells the front end which file to send. Set USE_X_SENDFILE = True when the server understands X-Sendfile (Apache with mod_xsendfile, lighttpd). With nginx the equivalent header is X-Accel-Redirect, which is not set by USE_X_SENDFILE: the view sets it itself, and the nginx location is marked internal. Both are covered further in Responses & JSON.

  • A CDN in front. The cache lifetime and the file-name hashing above are all a CDN needs. If the CDN also serves the assets from its own host name, the application must produce absolute URLs. The static_host argument is for host_matching applications; for a plain CDN a small template helper that prefixes the URL is simpler (example below). The CDN origin is nginx, not Flask.

  • Compression. Compress text assets in the proxy (gzip, or pre-compressed .gz and .br files with the respective nginx modules), or at build time. Flask’s static route does not compress.

def init_assets(app):
    """Register asset_url() so templates can write {{ asset_url('css/site.css') }}."""
    app.config.setdefault("ASSET_BASE_URL", "")

    @app.context_processor
    def assets():
        def asset_url(filename):
            url = url_for("static", filename=filename)
            base = app.config["ASSET_BASE_URL"].rstrip("/")
            return f"{base}{url}" if base else url

        return {"asset_url": asset_url}


app = Flask("bookshelf", root_path=str(work))
init_assets(app)


@app.get("/")
def home():
    return render_template_string("<link rel='stylesheet' href='{{ asset_url(\"css/site.css\") }}'>")


assert "href='/static/css/site.css'" in app.test_client().get("/").get_data(as_text=True)
app.config["ASSET_BASE_URL"] = "https://cdn.example.org/bookshelf/"
assert "https://cdn.example.org/bookshelf/static/css/site.css" in app.test_client().get("/").get_data(as_text=True)

The base URL comes from configuration (an environment variable in the application factory), so the same code runs against the local static route in development and the CDN in production.

Front-End Tooling: Vite Builds and a Manifest

A Flask application that needs more than a few hand-written files (TypeScript, a component framework, CSS preprocessing, minification, hashed file names) adds a bundler. The maintained choice today is Vite; Create React App, which the book Full-Stack Flask and React uses for its React project, was deprecated in February 2025 and is not a starting point for new work. The React side of such a project is covered by the React pages of this site (React); here the interest is the contract between the build and Flask.

Vite’s backend-integration guide describes it: set build.manifest so that the build writes .vite/manifest.json into the output folder, name the entry file as the Rollup input, and have the backend read the manifest to build the <script> and <link> tags. In development the backend instead emits two script tags that point at the Vite dev server: @vite/client and the entry file.

// vite.config.js (in the repository root, next to package.json)
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    manifest: true,
    outDir: "src/bookshelf/static/dist",
    emptyOutDir: true,
    rollupOptions: { input: "frontend/main.js" },
  },
  server: { origin: "http://localhost:5173" },
});

Each manifest entry is keyed by the source file and has a file field with the hashed output name, an optional css list and an optional imports list of shared chunks. A template global that reads the manifest once and renders the tags is about twenty lines (the test below writes a manifest by hand, so it does not need Node.js):

import json

from markupsafe import Markup, escape

write("static/dist/.vite/manifest.json", json.dumps({
    "frontend/main.js": {
        "file": "assets/main-3f9a1c2b.js",
        "src": "frontend/main.js",
        "isEntry": True,
        "css": ["assets/main-9b8e7d6c.css"],
    }
}))
write("static/dist/assets/main-3f9a1c2b.js", "console.log('built');")
write("static/dist/assets/main-9b8e7d6c.css", "body { margin: 0 }")


def init_vite(app, dist="dist", dev_origin=None):
    manifest_path = os.path.join(app.static_folder, dist, ".vite", "manifest.json")

    @lru_cache(maxsize=1)
    def manifest():
        with open(manifest_path) as handle:
            return json.load(handle)

    @app.template_global()
    def vite_tags(entry):
        if dev_origin:  # development: the Vite dev server serves and reloads modules
            return Markup(
                '<script type="module" src="{o}/@vite/client"></script>'
                '<script type="module" src="{o}/{e}"></script>'
            ).format(o=dev_origin, e=entry)
        item = manifest()[entry]
        parts = [
            Markup('<link rel="stylesheet" href="{}">').format(url_for("static", filename=f"{dist}/{css}"))
            for css in item.get("css", [])
        ]
        parts.append(
            Markup('<script type="module" src="{}"></script>').format(
                url_for("static", filename=f"{dist}/{item['file']}")
            )
        )
        return Markup("").join(parts)


app = Flask("bookshelf", root_path=str(work))
init_vite(app)


@app.get("/")
def home():
    return render_template_string("<head>{{ vite_tags('frontend/main.js') }}</head>")


page = app.test_client().get("/").get_data(as_text=True)
assert '<link rel="stylesheet" href="/static/dist/assets/main-9b8e7d6c.css">' in page
assert '<script type="module" src="/static/dist/assets/main-3f9a1c2b.js"></script>' in page

dev = Flask("bookshelf-dev", root_path=str(work))
init_vite(dev, dev_origin="http://localhost:5173")
dev.add_url_rule("/", "home", lambda: render_template_string("{{ vite_tags('frontend/main.js') }}"))
assert "http://localhost:5173/@vite/client" in dev.test_client().get("/").get_data(as_text=True)

Two details of the helper matter. The values are inserted with Markup.format, which escapes its arguments, and the manifest is data from the build, so a poisoned manifest cannot inject markup. And the production branch should fail loudly (a KeyError at the first request) when the entry is missing, rather than render a page without its script. In a project the build output belongs to .gitignore and is produced in CI, before the wheel or the Docker image is built; the layout page shows static/ inside the package so that the built files travel with it.

The Vite dev server is a separate process, so in development either the page loads modules from http://localhost:5173 (CORS must allow the Flask origin: server.cors.origin in Vite) or Vite proxies the Flask API through its server.proxy option. The single-page-application page of this section covers the second layout, where Flask serves only the JSON API and the built index.html.

JavaScript, fetch and JSON

A static script talks to the application through fetch and JSON. The official pattern has a few rules that the code below follows:

  • the template, not the script, knows the URLs. Render them with url_for and tojson and hand them to the script, because JavaScript runs in the browser and cannot call url_for. The same filter turns server data into a valid JavaScript value and escapes the characters that would break out of a <script> block;

  • if the value goes into a data- attribute, the attribute must be enclosed in single quotes, because the filter produces double-quoted JSON;

  • a request with a JSON body must carry Content-Type: application/json, otherwise request.json raises 415 Unsupported Media Type; a body that is not valid JSON gives 400;

  • a view that returns a dict or list answers JSON, and file data belongs in a separate request: put the URL of the cover in the JSON, not its bytes.

from flask import jsonify

write("static/js/books.js", """\
const list = document.querySelector("#books");
const csrf = document.querySelector("meta[name=csrf-token]");

async function loadBooks() {
  const response = await fetch(list.dataset.url);
  if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
  for (const book of await response.json()) {
    const item = document.createElement("li");
    item.textContent = `${book.title} (${book.year})`;   // textContent, never innerHTML
    list.append(item);
  }
}

async function addBook(book) {
  const headers = { "Content-Type": "application/json" };
  if (csrf) { headers["X-CSRFToken"] = csrf.content; }
  const response = await fetch(list.dataset.url, { method: "POST", headers, body: JSON.stringify(book) });
  return response.json();
}

loadBooks();
""")

BOOKS = [{"id": 1, "title": "Dune", "year": 1965}]

app = Flask("bookshelf", root_path=str(work))


@app.get("/books")
def books_page():
    return render_template_string(
        "<ul id='books' data-url='{{ url_for(\"api_books\")|tojson }}'></ul>"
        "<script src='{{ url_for(\"static\", filename=\"js/books.js\") }}' defer></script>"
    )


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


@app.post("/api/books")
def api_add_book():
    data = request.json  # 415 without the JSON content type, 400 for invalid JSON
    book = {"id": len(BOOKS) + 1, "title": data["title"], "year": data["year"]}
    BOOKS.append(book)
    return book, 201


client = app.test_client()
page = client.get("/books").get_data(as_text=True)
assert "data-url='\"/api/books\"'" in page          # tojson output, inside single quotes
assert "/static/js/books.js" in page
assert client.get("/api/books").get_json() == [{"id": 1, "title": "Dune", "year": 1965}]
assert client.post("/api/books", data='{"title": "Emma", "year": 1815}').status_code == 415
assert client.post("/api/books", data="{", content_type="application/json").status_code == 400
created = client.post("/api/books", json={"title": "Emma", "year": 1815})
assert created.status_code == 201 and created.get_json()["id"] == 2
with client.get("/static/js/books.js") as script:
    assert script.mimetype in ("text/javascript", "application/javascript")

The script reads the URL as list.dataset.url; because the attribute holds JSON, the value "/api/books" arrives with its quotes and a real project would call JSON.parse(list.dataset.url) once. Writing it with {{ url_for(...) }} without tojson and a plain double-quoted attribute works for simple strings but loses the escaping guarantees, which matters as soon as the value is data and not a URL.

A page that uses cookie-based authentication must send a CSRF token with every state-changing fetch. Flask-WTF reads it from the X-CSRFToken header; the script above reads the token from a meta tag when one is present. The token and the extension are explained in the Flask-WTF page of this section. Cross-origin calls from a separately served front end are the subject of the page about single-page applications and CORS.

Pitfalls and Security

Pitfall What to do

The application root served as the static folder

static_folder="." publishes the source tree, the instance folder with its configuration and any database file. Serve a dedicated folder only, and keep instance/ and uploads outside it.

User uploads under static/

Everything in the folder is public and uses the extension for its MIME type. Store uploads elsewhere and serve them through a view with send_from_directory or a proxy rule that adds Content-Disposition: attachment for types that must not render in the page.

HTML or SVG from users served inline

The browser executes the scripts in it in the site’s origin. The official security notes say never to send HTML from uploaded files and to use Content-Disposition: attachment. Treat user-supplied SVG like HTML.

MIME sniffing

Send X-Content-Type-Options: nosniff (an after_request function or the Flask-Talisman extension), so a text file cannot be reinterpreted as HTML or a script.

Content-Security-Policy blocking your own scripts

A strict policy such as default-src 'self' allows scripts from the static route but blocks inline <script> blocks, including the tojson data block. Prefer data attributes and external scripts, or use nonces.

Authentication hooks on the static route

before_request functions run for /static/. Exempt request.endpoint == "static" (or serve the files from the proxy) for the assets of the login page.

Symlinks inside served folders

safe_join does not resolve them. A link to a folder outside the root is followed; do not put links in a served folder.

A moved static_folder after construction

The route is registered by the constructor. Pass the final value to Flask(...), or rebuild the application.

Long max-age on stable URLs

Users keep an old stylesheet for a year. Combine long lifetimes only with hashed names or a version query.

The development server in production

flask run is not a production server. Use Gunicorn or Waitress with a proxy that serves the files.

Recent Changes and Where the Books Are Outdated

Version Change Consequence

Flask 2.0

SEND_FILE_MAX_AGE_DEFAULT defaults to None instead of 12 hours; send_file and send_from_directory moved into Werkzeug, and Flask’s versions wrap them.

Static files are revalidated by default. Old advice to lower a 12-hour cache to see changes during development no longer applies.

Flask 2.0 and 2.2

send_file argument renames (attachment_filename to download_name, cache_timeout to max_age, add_etags to etag) and send_from_directory(filename=) to path=; the old names were removed in 2.2.

Code from the books that passes the old names fails with TypeError.

Flask 2.2 and 2.3

FLASK_ENV and app.env removed.

Development behaviour is switched with flask --app bookshelf run --debug.

Flask 3.0

flask.Markup and flask.escape removed.

Import Markup and escape from markupsafe, as the Vite helper above does.

Werkzeug 3.1.9

safe_join rejects special Windows device names with an empty alternate data stream marker.

Applies to Windows hosts that serve user-chosen paths.

Flask 3.2 (unreleased)

No change to static files in the changelog.

Nothing to do.

The configuration chapter of the Flask Framework Cookbook shows static_folder and static_url_path as arguments of Flask(...), which is still correct, but its examples install the application with setup.py; the current layout is a pyproject.toml project run with the flask --app command. For the front end, Full-Stack Flask and React builds the React part with Create React App, which was deprecated in February 2025: use Vite as shown above. Where the books serve their built assets from Flask’s static route in production, use a proxy as described in the production section.