FastAPI/Starlette matches routes by path first, then applies HTTP-method routing. The auth router registers a parameterised route:
Wrote the solution to ~/solution-fastapi-wrong-verb-401-shadow-route.md and verified the behavior by reproducing it with FastAPI 0.142.2 / Starlette 1.7.0.
PUT /v1~ returns 401 Not authenticated instead of 405FastAPI/Starlette matches routes by path first, then applies HTTP-method routing. The auth router registers a parameterised route:
@router.put("/users/{user_id}")
def put_user(user_id: str, _=Depends(require_auth)): ...
{user_id} matches any single segment, so PUT /v1~ fully matches that route with user_id="login". Starlette never reaches its method-mismatch branch that would emit 405 Method Not Allowed. It enters the PUT /users/{user_id} endpoint, resolves dependencies, and require_auth raises 401 before the handler (and any method check) runs.
The same shadowing exists for GET ~ (shadowed by GET /users/{user_id}) and PUT ~ (shadowed by PUT /users/{user_id}).
Ordering alone does not fix this: a POST ~ route and a PUT /users/{user_id} route don't compete on the same method, so Starlette's PARTIAL handling still prefers the full path match. Explicit literal-path stubs are required.
Register literal-path method stubs on ~ and ~, for every verb that must return 405, before the /users/{user_id} routes. The stubs raise 405 with Allow: POST and stay out of the OpenAPI schema.
In the auth router (e.g. app/api/v1/routers/auth.py), place this immediately after the real POST handlers and before any /users/{user_id} routes:
from fastapi import APIRouter, HTTPException
router = APIRouter()
@router.post("~")
def login():
...
@router.post("~")
def register():
...
# --- Method-not-allowed stubs -------------------------------------------------
# These literal paths would otherwise be "shadowed" by /users/{user_id}: the
# wildcard route fully matches "login"/"register" and its bearer-auth dependency
# raises 401 before Starlette can emit a 405 for the wrong verb.
def _method_not_allowed() -> None:
raise HTTPException(
status_code=405,
detail="Method Not Allowed",
headers={"Allow": "POST"},
)
@router.get("~", include_in_schema=False)
@router.put("~", include_in_schema=False)
def login_method_not_allowed() -> None:
_method_not_allowed()
@router.put("~", include_in_schema=False)
def register_method_not_allowed() -> None:
_method_not_allowed()
# -----------------------------------------------------------------------------
@router.get("/users/{user_id}")
def get_user(user_id: str, _=Depends(require_auth)):
...
@router.put("/users/{user_id}")
def put_user(user_id: str, _=Depends(require_auth)):
...
Notes:
- include_in_schema=False keeps the stubs out of /openapi.json.
- Add a stub for every wrong verb you want to answer 405.
- If mounted with prefix="/v1", the literal paths are relative to the router and must match the same prefix as the real routes.
- The wildcard /users/{user_id} shadows all literal paths under /users/; give any new ones the same treatment.
Reproduction exercised with TestClient:
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Request
from fastapi.testclient import TestClient
def require_auth(request: Request):
if not request.headers.get("authorization"):
raise HTTPException(status_code=401, detail="Not authenticated")
return request.headers["authorization"]
def build(fix: bool):
app = FastAPI()
router = APIRouter()
@router.post("~")
def login():
return {"token": "x"}
@router.post("~")
def register():
return {"ok": True}
if fix:
def _mna():
raise HTTPException(
status_code=405,
detail="Method Not Allowed",
headers={"Allow": "POST"},
)
@router.get("~", include_in_schema=False)
@router.put("~", include_in_schema=False)
def login_mna():
_mna()
@router.put("~", include_in_schema=False)
def register_mna():
_mna()
@router.get("/users/{user_id}")
def get_user(user_id: str, _=Depends(require_auth)):
return {"user_id": user_id}
@router.put("/users/{user_id}")
def put_user(user_id: str, _=Depends(require_auth)):
return {"user_id": user_id}
app.include_router(router, prefix="/v1")
return app
for fix in (False, True):
c = TestClient(build(fix))
print("FIX" if fix else "BUG")
for method, path in [
("PUT", "/v1~"),
("GET", "/v1~"),
("PUT", "/v1~"),
]:
r = c.request(method, path)
print(f" {method} {path} -> {r.status_code} Allow={r.headers.get('allow')}")
assert c.post("/v1~").status_code == 200
assert c.post("/v1~").status_code == 200
assert c.get("/v1~", headers={"authorization": "Bearer t"}).json() == {
"user_id": "42"
}
Observed output:
BUG
PUT /v1~ -> 401 Allow=None
GET /v1~ -> 401 Allow=None
PUT /v1~ -> 401 Allow=None
FIX
PUT /v1~ -> 405 Allow=POST
GET /v1~ -> 405 Allow=POST
PUT /v1~ -> 405 Allow=POST
Acceptance checks:
1. PUT /v1~ → 405, Allow: POST (not 401).
2. GET /v1~ → 405, Allow: POST.
3. PUT /v1~ → 405, Allow: POST.
4. POST /v1~ and POST /v1~ still succeed.
5. Authenticated GET/PUT /v1/users/{user_id} still work.
6. Stubs absent from /openapi.json.
# Evidence - Problem class: fastapi-wrong-verb-401-shadow-route - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-01T08:26:21.243Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "fastapi-wrong-verb-401-shadow-route", "provider": "openrouter", "solved_at": "2026-10-01T08:26:21.245Z", "version": ""}