How to find every FastAPI route that reaches your database without auth
FastAPI can attach auth at five layers, in different files. How to list the routes that have none, and find the ones that reach your database.
Try WyroFastAPI makes authentication one line. Add Depends(get_current_user) and the route turns away anyone without a valid token. That is also why a route without it is easy to miss: nothing fails, the tests that sign in first still pass, and a missing line looks like every other line that isn't there. The FastAPI docs explain how to add auth. This guide is about the other question: in a codebase that already exists, which routes reach your database without it?
Where a FastAPI route gets its auth
A route collects its dependencies from five places, and it is authenticated if a guard appears at any one of them:
- The app.
FastAPI(dependencies=[Depends(get_current_user)])applies to every route, including every router the app includes. - The mount.
app.include_router(router, dependencies=[...])applies to everything on that router. So does a parent: routers included intoapi_router = APIRouter(dependencies=[...])inherit its dependencies and its prefix. - The router.
APIRouter(prefix="/notes", dependencies=[...])applies to every route declared on it. - The decorator.
@router.delete("/{note_id}", dependencies=[...])applies to that one route. It is the usual place for a guard whose return value the handler doesn't need. - The signature.
user: User = Depends(get_current_user), or the modern form,user: CurrentUser, whereCurrentUser = Annotated[User, Depends(get_current_user)]is declared once in adeps.py.
![Five nested boxes, outermost first. 1, App, in main.py: app = FastAPI(dependencies=[Depends(get_current_user)]). 2, Mount, in main.py: api_router.include_router(notes.router, dependencies=[...]). 3, Router, in routes/notes.py: router = APIRouter(prefix="/notes", dependencies=[...]). 4, Decorator, in routes/notes.py: @router.delete("/{note_id}", dependencies=[...]). 5, Signature, in routes/notes.py: def delete_note(note_id: int, user: CurrentUser), where CurrentUser is an Annotated alias declared in deps.py.](/blog/fastapi-routes-without-auth/where-auth-lives.png)
Security() is Depends() with OAuth2 scopes, and counts the same at every layer. And a dependency list is not one dependency: [Depends(get_db), Depends(get_current_user)] authenticates the route because of its second entry.
Why grep doesn't answer it
The obvious first pass is to search for route decorators and look for get_current_user nearby. It goes wrong in both directions:
- It flags every route protected at the router, the mount or the app, because none of those write anything next to the route. In a codebase that authenticates at the router, that is nearly every route.
- It trusts any
Depends(...)it sees, and most of them are not auth.Depends(get_db)opens a database session. An alias calledSessionDephas "session" in its name and is also justget_db. - It can't tell a health check from a delete. Both are unauthenticated, and only one of them is a problem.
The question has two halves and grep answers neither: what protects this route, which depends on files other than the one it is in, and what data it reaches, which depends on the handler and everything it calls.
Ask FastAPI: list routes with no security requirement
FastAPI already works out each route's full dependency list, across all five layers, when it builds the OpenAPI schema. Any dependency that uses one of its security classes (OAuth2PasswordBearer, HTTPBearer, APIKeyHeader and the rest) adds a security requirement to the operation. So the operations without one are the routes with no recognised auth:
# list_open_routes.py
from app.main import app # wherever your FastAPI() instance lives
for path, operations in app.openapi()["paths"].items():
for method, op in operations.items():
if not op.get("security"):
print(f"{method.upper():<7} {path}")We ran it on a small example app: notes, users, a login route and a health check, with the guard left off the delete route on purpose. It prints:
$ python list_open_routes.py
POST /api/v1/login/access-token
DELETE /api/v1/notes/{note_id}
GET /api/v1/healthThree routes, one of them the real problem. Know the limits before you trust an empty result:
- A guard that reads a header or cookie by hand, without one of FastAPI's security classes, adds no
securityentry. Its routes are listed as open even though they aren't. - Routes declared with
include_in_schema=Falseare not in the schema, so they can never be listed. - Auth done in a Starlette middleware is invisible to the schema, so every route is listed.
- It has to import your app, which in CI usually means the same environment variables and settings your server needs.
Only open routes that reach data matter
An open health check is fine. An open sign-in route is required. The one that matters is an open route that reaches a table, and finding it means reading what each handler does: its queries, the repository or service it is handed, and what those call.
That is what Wyro's check does. It reads the source without running it: FastAPI routes at all five layers, across files and through nested include_router chains, SQLAlchemy and SQLModel models, and each handler's access to them, including through injected repository classes. Then it reports every route that can reach a table without passing a guard. No model is involved, and the same code always gets the same answer. On the example app:
$ node wyro-check.js .
read 11 files · 6 routes · 2 tables
✗ [HIGH · high confidence] DELETE /api/v1/notes/{note_id} can reach notes without authenticating.
DELETE /api/v1/notes/{note_id} → notes
An unauthenticated caller can write to notes. Writes are how data gets corrupted or planted.
missing: Authentication before the handler reaches data, and a query scoped to the caller.
…
! [LOW · high confidence] POST /api/v1/login/access-token reaches users without auth, which is expected for an authentication endpoint.
…
1 error · 4 warningsOne error: the delete. The sign-in route is reported as informational, because a login route has to read the users table before anyone is signed in. The health check touches no table, so it isn't an auth finding at all. The other three warnings (trimmed above) are low-confidence notes about request validation and rate limits, which often live where a parser can't see them.
A real example: the official full-stack template
The official full-stack FastAPI template is a good test, because it authenticates the modern way: CurrentUser and SessionDep aliases in deps.py, used in signatures across its routers. The check reads 23 routes and 2 tables and reports one error:

POST /private/users creates a user, password hash included, with no guard. At the time of writing it is also only mounted when settings.FASTAPI_ENV == "development": a local helper for creating test users. Reading the source can't tell you what that setting will be where the app is deployed, so this is the kind of finding a person has to judge. It is fine if the condition holds everywhere the app runs, and serious if it doesn't. What the check can do is make sure someone looks, at the right file and line.
Each finding comes with a request that proves it, a fix, and a regression test to keep it fixed:

What the check doesn't cover
- Whether a query is scoped to the signed-in user. A route that requires login but lets any user delete any note by id passes this check. Catching that needs a test that signs in as one user and reaches for another's data.
- Auth done in Starlette middleware. The check can't see it, so it reports that nothing in the backend authenticates. Setting
"policy": { "auth": "external" }in awyro.jsonrecords that decision and turns the finding into a warning that still lists every open route. - Guards it doesn't recognise by name. A dependency counts as a guard when its name says so: auth, current, token, jwt, verify, require, permission, admin, session and similar. A guard called
get_useris reported as missing. Renaming itget_current_userfixes the report and makes the route easier to read. - Data access through plain module functions. A handler that writes through
crud.create_user(session, ...)isn't followed intocrudyet, so the route looks like it touches no table and can't be flagged. In the template above,POST /users/signupwrites a user this way. It is a sign-up route and meant to be public, but the check isn't what tells you so. - Django and Flask. This is FastAPI with SQLAlchemy or SQLModel.
What writing this found wrong with the checker
Before publishing a guide to the five layers, we checked that Wyro reads all five. It didn't. Three ways of writing auth that the FastAPI docs teach were read as no auth at all, and in a checker that means a false accusation. We fixed those, and one smaller thing found along the way:
- Fixed
FastAPI(dependencies=[...])was applied to routes declared on the app, but not to the routers it includes, which is where nearly every route lives. - FixedA parent router's own prefix and dependencies weren't passed to the routers included into it, so
api_router = APIRouter(prefix="/api", dependencies=[...])produced routes with the wrong path and no auth. - FixedOnly the last entry of a router's dependency list was tested, so
[Depends(validate_session), Depends(set_error_format)]read as public.Security(...)in the list wasn't read at all, and a list with a]inside it, such asscopes=["me"], was cut short. - FixedSQLAlchemy 2.0's
class Base(DeclarativeBase): passwas reported as a model the check couldn't read. It is the registry every model subclasses, and it sat in 13 of the 37 FastAPI repositories we re-checked.
We ran the check over 37 open-source FastAPI repositories before and after the fixes: the 34 in the scan directory and 3 more we use to validate the parser. No error appeared or disappeared. Two repositories lost 128 warnings between them, all about public routes lacking validation or rate limits, on routes that turned out not to be public. Five had their route paths corrected to include a parent router's prefix, and the other 31 came out identical. Every fix has a regression test that fails on the old code.
Check your own
For a public repository, paste it into the scan page. There's no account, and every finding comes with its file and line. For a private one, the same check runs entirely on your machine with Node 18 or later:
curl -fsSL https://wyro.in/wyro-check.js -o wyro-check.js
node wyro-check.js path/to/your/backendTo fail a pull request that adds an unauthenticated route, see the CI docs. For how often this turns up in practice, we ran the same check over 100 open-source backends.