From 95f571efc9588bfa4da07ec09fa3f28c2a3a47b6 Mon Sep 17 00:00:00 2001 From: faisalsaificode Date: Sat, 4 Apr 2026 01:04:30 +0530 Subject: [PATCH 1/3] Add AuthStaticFiles to support authentication for static file serving Adds a new AuthStaticFiles class that extends StaticFiles with an `auth` parameter, allowing users to protect static files behind authentication. This addresses a long-standing feature request (issue #858) where mounted static files bypass FastAPI's dependency injection and serve files without any access control. Closes #858 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../static_files/tutorial002_auth_py310.py | 19 +++ fastapi/staticfiles.py | 91 +++++++++++ tests/test_auth_static_files.py | 154 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 docs_src/static_files/tutorial002_auth_py310.py create mode 100644 tests/test_auth_static_files.py diff --git a/docs_src/static_files/tutorial002_auth_py310.py b/docs_src/static_files/tutorial002_auth_py310.py new file mode 100644 index 0000000000000..b9cd40e404f54 --- /dev/null +++ b/docs_src/static_files/tutorial002_auth_py310.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.staticfiles import AuthStaticFiles + + +async def verify_token(request: Request) -> None: + """Check for a valid Bearer token in the Authorization header.""" + token = request.headers.get("Authorization") + if token != "Bearer mysecrettoken": + raise HTTPException(status_code=401, detail="Not authenticated") + + +app = FastAPI() + +# Private files - requires a valid token to access +app.mount( + "/private", + AuthStaticFiles(directory="private_files", auth=verify_token), + name="private", +) diff --git a/fastapi/staticfiles.py b/fastapi/staticfiles.py index 299015d4fef26..548b27b94422c 100644 --- a/fastapi/staticfiles.py +++ b/fastapi/staticfiles.py @@ -1 +1,92 @@ +from typing import Any, Awaitable, Callable + +from starlette.requests import Request +from starlette.responses import JSONResponse from starlette.staticfiles import StaticFiles as StaticFiles # noqa +from starlette.types import Receive, Scope, Send + + +class AuthStaticFiles(StaticFiles): + """ + A static files handler that requires authentication before serving files. + + This solves the problem where `app.mount("/static", StaticFiles(...))` serves + files without any authentication, making it impossible to protect private files. + + `AuthStaticFiles` accepts an `auth` callable that receives a `Request` and + should either return successfully (authenticated) or raise an `HTTPException` + (not authenticated). + + ## Usage + + ```python + from fastapi import FastAPI, HTTPException, Request + from fastapi.staticfiles import AuthStaticFiles + + app = FastAPI() + + + async def verify_token(request: Request) -> None: + token = request.headers.get("Authorization") + if token != "Bearer mysecrettoken": + raise HTTPException(status_code=401, detail="Unauthorized") + + + app.mount( + "/private", + AuthStaticFiles(directory="private_files", auth=verify_token), + name="private", + ) + ``` + + ## Parameters + + * `auth`: An async callable that takes a `Request` object and performs + authentication. It should raise an `HTTPException` if authentication + fails, or return `None` if authentication succeeds. + * `directory`: The directory to serve files from. + * `packages`: A list of Python packages to serve files from. + * `html`: If `True`, serves `index.html` files for directories. + * `check_dir`: If `True`, checks that the directory exists on startup. + * `follow_symlink`: If `True`, follows symbolic links. + + Ref: https://github.com/fastapi/fastapi/issues/858 + """ + + def __init__( + self, + *, + directory: str | None = None, + packages: list[str | tuple[str, str]] | None = None, + html: bool = False, + check_dir: bool = True, + follow_symlink: bool = False, + auth: Callable[[Request], Awaitable[Any]], + ) -> None: + super().__init__( + directory=directory, + packages=packages, + html=html, + check_dir=check_dir, + follow_symlink=follow_symlink, + ) + self.auth = auth + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + request = Request(scope, receive) + try: + await self.auth(request) + except Exception as exc: + from fastapi.exceptions import HTTPException + + if isinstance(exc, HTTPException): + response = JSONResponse( + {"detail": exc.detail}, + status_code=exc.status_code, + headers=getattr(exc, "headers", None), + ) + await response(scope, receive, send) + return + raise + await super().__call__(scope, receive, send) diff --git a/tests/test_auth_static_files.py b/tests/test_auth_static_files.py new file mode 100644 index 0000000000000..d71c15202a44b --- /dev/null +++ b/tests/test_auth_static_files.py @@ -0,0 +1,154 @@ +import os +from pathlib import Path + +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.staticfiles import AuthStaticFiles +from fastapi.testclient import TestClient + + +@pytest.fixture(scope="module") +def static_dir(tmp_path_factory): + d = tmp_path_factory.mktemp("static") + (d / "public.txt").write_text("public content") + (d / "secret.txt").write_text("secret content") + return d + + +async def verify_token(request: Request) -> None: + """Simple token-based auth for testing.""" + token = request.headers.get("Authorization") + if token != "Bearer valid-token": + raise HTTPException(status_code=401, detail="Not authenticated") + + +@pytest.fixture(scope="module") +def app(static_dir): + app = FastAPI() + + # Public static files (no auth) + app.mount( + "/public", + AuthStaticFiles( + directory=str(static_dir), + auth=_allow_all, + ), + name="public", + ) + + # Private static files (requires auth) + app.mount( + "/private", + AuthStaticFiles( + directory=str(static_dir), + auth=verify_token, + ), + name="private", + ) + + return app + + +async def _allow_all(request: Request) -> None: + """Auth function that allows all requests.""" + pass + + +@pytest.fixture(scope="module") +def client(app): + with TestClient(app) as c: + yield c + + +def test_private_file_without_auth(client: TestClient): + """Requesting a private file without auth should return 401.""" + response = client.get("/private/secret.txt") + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +def test_private_file_with_wrong_token(client: TestClient): + """Requesting a private file with wrong token should return 401.""" + response = client.get( + "/private/secret.txt", + headers={"Authorization": "Bearer wrong-token"}, + ) + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +def test_private_file_with_valid_token(client: TestClient): + """Requesting a private file with valid token should return the file.""" + response = client.get( + "/private/secret.txt", + headers={"Authorization": "Bearer valid-token"}, + ) + assert response.status_code == 200 + assert response.text == "secret content" + + +def test_private_file_not_found_with_valid_token(client: TestClient): + """Requesting a non-existent private file with valid auth should return 404.""" + response = client.get( + "/private/nonexistent.txt", + headers={"Authorization": "Bearer valid-token"}, + ) + assert response.status_code == 404 + + +def test_public_files_accessible(client: TestClient): + """Public mount with allow-all auth should serve files without auth.""" + response = client.get("/public/public.txt") + assert response.status_code == 200 + assert response.text == "public content" + + +def test_auth_headers_forwarded(static_dir): + """Auth errors with custom headers should forward them in the response.""" + + async def auth_with_headers(request: Request) -> None: + raise HTTPException( + status_code=401, + detail="Login required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + app = FastAPI() + app.mount( + "/protected", + AuthStaticFiles(directory=str(static_dir), auth=auth_with_headers), + name="protected", + ) + + with TestClient(app) as client: + response = client.get("/protected/public.txt") + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.json() == {"detail": "Login required"} + + +def test_cookie_based_auth(static_dir): + """AuthStaticFiles should work with cookie-based authentication.""" + + async def verify_cookie(request: Request) -> None: + session = request.cookies.get("session_id") + if session != "valid-session": + raise HTTPException(status_code=403, detail="Forbidden") + + app = FastAPI() + app.mount( + "/dashboard", + AuthStaticFiles(directory=str(static_dir), auth=verify_cookie), + name="dashboard", + ) + + with TestClient(app) as client: + # Without cookie + response = client.get("/dashboard/public.txt") + assert response.status_code == 403 + + # With valid cookie + client.cookies.set("session_id", "valid-session") + response = client.get("/dashboard/public.txt") + assert response.status_code == 200 + assert response.text == "public content" From 6513de420df4c4b4699ae6d6ec019ff2ac9913b8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:38:11 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/staticfiles.py | 3 ++- tests/test_auth_static_files.py | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/fastapi/staticfiles.py b/fastapi/staticfiles.py index 548b27b94422c..3dd6a2f720fda 100644 --- a/fastapi/staticfiles.py +++ b/fastapi/staticfiles.py @@ -1,4 +1,5 @@ -from typing import Any, Awaitable, Callable +from collections.abc import Awaitable, Callable +from typing import Any from starlette.requests import Request from starlette.responses import JSONResponse diff --git a/tests/test_auth_static_files.py b/tests/test_auth_static_files.py index d71c15202a44b..8ecf42ba197ea 100644 --- a/tests/test_auth_static_files.py +++ b/tests/test_auth_static_files.py @@ -1,6 +1,3 @@ -import os -from pathlib import Path - import pytest from fastapi import FastAPI, HTTPException, Request from fastapi.staticfiles import AuthStaticFiles From edd5be62d69b50c2a5744b6564ca2d039d06f2de Mon Sep 17 00:00:00 2001 From: faisalsaificode Date: Sun, 5 Apr 2026 22:59:24 +0530 Subject: [PATCH 3/3] Address review feedback: plain text errors, configurable on_error, perf docs - Changed default error response from JSON to plain text (browser-friendly) - Added optional `on_error` callback for custom error responses (redirect to login page, HTML error pages, etc.) - Added performance note about keeping auth checks lightweight - Added tests for redirect and HTML custom error responses Co-Authored-By: Claude Opus 4.6 (1M context) --- fastapi/staticfiles.py | 28 ++++++++++--- tests/test_auth_static_files.py | 73 +++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/fastapi/staticfiles.py b/fastapi/staticfiles.py index 3dd6a2f720fda..978f83de11d4f 100644 --- a/fastapi/staticfiles.py +++ b/fastapi/staticfiles.py @@ -2,7 +2,7 @@ from typing import Any from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import PlainTextResponse, Response from starlette.staticfiles import StaticFiles as StaticFiles # noqa from starlette.types import Receive, Scope, Send @@ -45,12 +45,23 @@ async def verify_token(request: Request) -> None: * `auth`: An async callable that takes a `Request` object and performs authentication. It should raise an `HTTPException` if authentication fails, or return `None` if authentication succeeds. + * `on_error`: An optional callable that takes a `Request` and an + `HTTPException` and returns a `Response`. Use this to customize + error responses (e.g., redirect to login, return HTML instead of + plain text). If not provided, a plain text error response is returned. * `directory`: The directory to serve files from. * `packages`: A list of Python packages to serve files from. * `html`: If `True`, serves `index.html` files for directories. * `check_dir`: If `True`, checks that the directory exists on startup. * `follow_symlink`: If `True`, follows symbolic links. + ## Performance Note + + The `auth` callable runs on **every static file request** (CSS, JS, + images, etc.). Prefer lightweight checks (header presence, JWT signature + verification) over expensive operations (database lookups) to avoid + slowing down page loads. + Ref: https://github.com/fastapi/fastapi/issues/858 """ @@ -63,6 +74,7 @@ def __init__( check_dir: bool = True, follow_symlink: bool = False, auth: Callable[[Request], Awaitable[Any]], + on_error: Callable[[Request, Any], Awaitable[Response]] | None = None, ) -> None: super().__init__( directory=directory, @@ -72,6 +84,7 @@ def __init__( follow_symlink=follow_symlink, ) self.auth = auth + self.on_error = on_error async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "http": @@ -82,11 +95,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: from fastapi.exceptions import HTTPException if isinstance(exc, HTTPException): - response = JSONResponse( - {"detail": exc.detail}, - status_code=exc.status_code, - headers=getattr(exc, "headers", None), - ) + if self.on_error is not None: + response = await self.on_error(request, exc) + else: + response = PlainTextResponse( + str(exc.detail), + status_code=exc.status_code, + headers=getattr(exc, "headers", None), + ) await response(scope, receive, send) return raise diff --git a/tests/test_auth_static_files.py b/tests/test_auth_static_files.py index 8ecf42ba197ea..10be23affd8fe 100644 --- a/tests/test_auth_static_files.py +++ b/tests/test_auth_static_files.py @@ -1,7 +1,9 @@ import pytest from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import RedirectResponse from fastapi.staticfiles import AuthStaticFiles from fastapi.testclient import TestClient +from starlette.responses import HTMLResponse, Response @pytest.fixture(scope="module") @@ -19,6 +21,11 @@ async def verify_token(request: Request) -> None: raise HTTPException(status_code=401, detail="Not authenticated") +async def _allow_all(request: Request) -> None: + """Auth function that allows all requests.""" + pass + + @pytest.fixture(scope="module") def app(static_dir): app = FastAPI() @@ -46,11 +53,6 @@ def app(static_dir): return app -async def _allow_all(request: Request) -> None: - """Auth function that allows all requests.""" - pass - - @pytest.fixture(scope="module") def client(app): with TestClient(app) as c: @@ -61,7 +63,7 @@ def test_private_file_without_auth(client: TestClient): """Requesting a private file without auth should return 401.""" response = client.get("/private/secret.txt") assert response.status_code == 401 - assert response.json() == {"detail": "Not authenticated"} + assert response.text == "Not authenticated" def test_private_file_with_wrong_token(client: TestClient): @@ -71,7 +73,7 @@ def test_private_file_with_wrong_token(client: TestClient): headers={"Authorization": "Bearer wrong-token"}, ) assert response.status_code == 401 - assert response.json() == {"detail": "Not authenticated"} + assert response.text == "Not authenticated" def test_private_file_with_valid_token(client: TestClient): @@ -121,7 +123,7 @@ async def auth_with_headers(request: Request) -> None: response = client.get("/protected/public.txt") assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == "Bearer" - assert response.json() == {"detail": "Login required"} + assert response.text == "Login required" def test_cookie_based_auth(static_dir): @@ -149,3 +151,58 @@ async def verify_cookie(request: Request) -> None: response = client.get("/dashboard/public.txt") assert response.status_code == 200 assert response.text == "public content" + + +def test_custom_on_error_redirect(static_dir): + """on_error can redirect to a login page.""" + + async def deny_all(request: Request) -> None: + raise HTTPException(status_code=401, detail="Unauthorized") + + async def redirect_to_login(request: Request, exc: HTTPException) -> Response: + return RedirectResponse(url="/login", status_code=302) + + app = FastAPI() + app.mount( + "/protected", + AuthStaticFiles( + directory=str(static_dir), + auth=deny_all, + on_error=redirect_to_login, + ), + name="protected", + ) + + with TestClient(app, follow_redirects=False) as client: + response = client.get("/protected/public.txt") + assert response.status_code == 302 + assert response.headers["location"] == "/login" + + +def test_custom_on_error_html(static_dir): + """on_error can return a custom HTML error page.""" + + async def deny_all(request: Request) -> None: + raise HTTPException(status_code=403, detail="Forbidden") + + async def html_error(request: Request, exc: HTTPException) -> Response: + return HTMLResponse( + f"

{exc.status_code} {exc.detail}

", + status_code=exc.status_code, + ) + + app = FastAPI() + app.mount( + "/protected", + AuthStaticFiles( + directory=str(static_dir), + auth=deny_all, + on_error=html_error, + ), + name="protected", + ) + + with TestClient(app) as client: + response = client.get("/protected/public.txt") + assert response.status_code == 403 + assert "

403 Forbidden

" in response.text