Post

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 Wyro

FastAPI 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 into api_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, where CurrentUser = Annotated[User, Depends(get_current_user)] is declared once in a deps.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.
The five layers, using the example app from later in this post. A guard at any layer covers every route inside it.

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 called SessionDep has "session" in its name and is also just get_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/health

Three 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 security entry. Its routes are listed as open even though they aren't.
  • Routes declared with include_in_schema=False are 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 warnings

One 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:

Wyro scan report for fastapi/full-stack-fastapi-template, backend directory: 4 findings, 1 critical, 2 medium, 1 low. The critical finding reads: POST /private/users can reach user without authenticating. Anyone, signed in or not, can write to user (email, hashed_password). Where: backend/app/api/routes/private.py:23. Missing: authentication before the handler reaches data, and a query scoped to the caller. Below it, a curl command that sends the request with no credentials and expects 401.
The public scan page for the template's backend, captured 29 September 2026. The finding is the real result for the repository as it stood that day.

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:

The Fix and Regression test sections of the same finding. Fix: a POST handler for /private/users with user: User = Depends(get_current_user), noting that get_current_user raises HTTPException(401) when the token is missing. Regression test: def test_post_requires_auth(client) posts to /private/users and asserts the status code is 401.
The rest of the same finding. The fix is a generic pattern for the framework, not a patch written for this repository.

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 a wyro.json records 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_user is reported as missing. Renaming it get_current_user fixes 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 into crud yet, so the route looks like it touches no table and can't be flagged. In the template above, POST /users/signup writes 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:

  • FixedFastAPI(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 as scopes=["me"], was cut short.
  • FixedSQLAlchemy 2.0's class Base(DeclarativeBase): pass was 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/backend

To 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.

  • guide
  • fastapi
  • python
  • authentication
  • security