π― What You Will Build
A clean authorization layer for FastAPI: routes that only admins can call, permission checks that scale better than roles, and resource ownership rules β all built with reusable dependencies, not copy-pasted if checks.
- Authentication vs authorization (401 vs 403)
- A
require_role(...)dependency factory - Roles β permissions mapping and
require_permission(...) - Guarding an entire router with one dependency
- Ownership checks (role isn't always enough)
- Returning clean
403responses
Prerequisites: comfort with Depends() (fastapi-dependency-injection) and a way to identify the current user (fastapi-jwt). We'll fake the tokenβuser step so we can focus on authorization.
fastapi-rbac/
βββ requirements.txt
βββ app/
βββ __init__.py
βββ main.py # routes
βββ security.py # current user + role/permission deps
βββ rbac.py # roles, permissions, mappingAuthentication vs Authorization
These get mixed up constantly. Authentication answers "who are you?" (login, tokens). Authorization answers "are you allowed to do this?" (roles, permissions). RBAC is the authorization half.
| Question | Concept | Failure code |
|---|---|---|
| Who are you? | Authentication (JWT, session) | 401 Unauthorized |
| Are you allowed? | Authorization (RBAC) | 403 Forbidden |
403, not a 401. 401 means "log in"; 403 means "you're logged in, but no."The Problem: Role Checks Everywhere (See It First)
Without a pattern, authorization logic leaks into every handler and drifts out of sync:
from fastapi import FastAPI, Depends, HTTPException
app = FastAPI()
@app.delete("/users/{user_id}")
def delete_user(user_id: int, user=Depends(get_current_user)):
if user["role"] != "admin": # check here
raise HTTPException(status_code=403, detail="No")
...
@app.post("/reports")
def create_report(user=Depends(get_current_user)):
if user["role"] != "admin" and user["role"] != "manager": # ...and here
raise HTTPException(status_code=403, detail="No")
..."No" vs a real message), and impossible to audit. Add a manager role and you're editing every endpoint. RBAC moves this into one reusable place.The Current-User Dependency
Everything starts from knowing who the caller is. In a real app this decodes a JWT; here we fake it with a token header so the focus stays on authorization. Define roles as an Enum to avoid typos.
from enum import StrEnum
class Role(StrEnum):
ADMIN = "admin"
MANAGER = "manager"
USER = "user"from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from app.rbac import Role
# Pretend token -> user. In production this decodes a JWT.
FAKE_USERS = {
"token-admin": {"id": 1, "username": "thirdy", "role": Role.ADMIN},
"token-manager": {"id": 2, "username": "maria", "role": Role.MANAGER},
"token-user": {"id": 3, "username": "juan", "role": Role.USER},
}
def get_current_user(authorization: str = Header(...)) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Bearer token")
token = authorization.removeprefix("Bearer ")
user = FAKE_USERS.get(token)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return user
CurrentUser = Annotated[dict, Depends(get_current_user)]StrEnum (Python 3.11+) gives you Role.ADMIN == "admin", so it serializes to JSON as a plain string but you still get autocomplete and typo protection in code.The require_role Dependency Factory
Here's the key idea: a dependency factory β a function that returns a dependency. Call require_role(Role.ADMIN) and you get a dependency configured for that role.
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from app.rbac import Role
def require_role(*allowed: Role) -> Callable[..., dict]:
def checker(user: CurrentUser) -> dict:
if user["role"] not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of: {', '.join(r.value for r in allowed)}",
)
return user
return checkerfrom typing import Annotated
from fastapi import Depends, FastAPI
from app.rbac import Role
from app.security import CurrentUser, require_role
app = FastAPI(title="FastAPI RBAC", version="0.1.0")
@app.get("/me")
def me(user: CurrentUser):
return {"username": user["username"], "role": user["role"]}
# admin only
@app.delete("/users/{user_id}")
def delete_user(user_id: int, admin: Annotated[dict, Depends(require_role(Role.ADMIN))]):
return {"deleted": user_id, "by": admin["username"]}
# admin OR manager
@app.post("/reports")
def create_report(user: Annotated[dict, Depends(require_role(Role.ADMIN, Role.MANAGER))]):
return {"created_by": user["username"], "role": user["role"]}require_role. Endpoints just declare which roles they need β readable, consistent, and easy to audit.From Roles to Permissions (Scales Better)
Roles alone get messy fast: "admin OR manager OR editor ORβ¦" scattered across routes. A more scalable model checks permissions, and each role owns a set of permissions. Endpoints ask for a capability, not a title.
from enum import StrEnum
class Role(StrEnum):
ADMIN = "admin"
MANAGER = "manager"
USER = "user"
class Permission(StrEnum):
USER_DELETE = "user:delete"
REPORT_CREATE = "report:create"
REPORT_READ = "report:read"
# One place that defines what each role can do.
ROLE_PERMISSIONS: dict[Role, set[Permission]] = {
Role.ADMIN: {Permission.USER_DELETE, Permission.REPORT_CREATE, Permission.REPORT_READ},
Role.MANAGER: {Permission.REPORT_CREATE, Permission.REPORT_READ},
Role.USER: {Permission.REPORT_READ},
}
def role_has_permission(role: Role, permission: Permission) -> bool:
return permission in ROLE_PERMISSIONS.get(role, set())from collections.abc import Callable
from fastapi import HTTPException, status
from app.rbac import Permission, Role, role_has_permission
def require_permission(permission: Permission) -> Callable[..., dict]:
def checker(user: CurrentUser) -> dict:
if not role_has_permission(Role(user["role"]), permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {permission.value}",
)
return user
return checkerfrom app.rbac import Permission
from app.security import require_permission
@app.delete("/users/{user_id}")
def delete_user(
user_id: int,
user: Annotated[dict, Depends(require_permission(Permission.USER_DELETE))],
):
return {"deleted": user_id, "by": user["username"]}ROLE_PERMISSIONS in one place β no endpoints touched. Routes stay stable because they ask for a capability (report:create), not a role.Guarding a Whole Router
When an entire group of routes needs the same role, attach the dependency once on the router instead of every endpoint:
from fastapi import APIRouter, Depends
from app.rbac import Role
from app.security import require_role
# Every route here requires admin. No per-route repetition.
admin_router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_role(Role.ADMIN))],
)
@admin_router.get("/stats")
def stats():
return {"users": 3, "reports": 12}
@admin_router.get("/audit-log")
def audit_log():
return {"entries": []}
app.include_router(admin_router)dependencies=[...] on the router when you only need the guard (the side effect). If a handler needs the user object, also declare it as a parameter β FastAPI caches it, so it's resolved once.Ownership Checks (Role Isn't Everything)
RBAC answers "can this role do X?" but often the real rule is "can this user act on their own resource?" A regular user should edit their own profile β but not someone else's. That's object-level authorization, and it lives inside the handler where you know the resource.
from fastapi import HTTPException, status
from app.rbac import Role
PROFILES = {3: {"owner_id": 3, "bio": "Hi, I'm Juan"}}
@app.patch("/profiles/{owner_id}")
def update_profile(owner_id: int, user: CurrentUser):
# Admins can edit anyone; everyone else only their own profile.
if user["role"] != Role.ADMIN and user["id"] != owner_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own profile",
)
# ... apply the update ...
return {"owner_id": owner_id, "updated_by": user["username"]}PATCH /profiles/{id}" + no ownership check = anyone edits anyone. Always pair role checks with an ownership check on user-owned resources.Try It (curl)
fastapi==0.116.1
uvicorn[standard]==0.30.6python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload# admin can delete
$ curl -s -X DELETE http://127.0.0.1:8000/users/9 -H "Authorization: Bearer token-admin"
{"deleted":9,"by":"thirdy"}
# regular user is forbidden (403, not 401)
$ curl -s -X DELETE http://127.0.0.1:8000/users/9 -H "Authorization: Bearer token-user"
{"detail":"Missing permission: user:delete"}
# no token at all -> 401 (authentication, not authorization)
$ curl -s -X DELETE http://127.0.0.1:8000/users/9
{"detail":"Missing Bearer token"}
# manager can create reports, user cannot
$ curl -s -X POST http://127.0.0.1:8000/reports -H "Authorization: Bearer token-manager"
{"created_by":"maria","role":"manager"}
# ownership: user edits their OWN profile (id 3) β ok
$ curl -s -X PATCH http://127.0.0.1:8000/profiles/3 -H "Authorization: Bearer token-user"
{"owner_id":3,"updated_by":"juan"}
# ownership: same user tries to edit someone else's β 403
$ curl -s -X PATCH http://127.0.0.1:8000/profiles/1 -H "Authorization: Bearer token-user"
{"detail":"You can only edit your own profile"}401/403, with rules defined in one place.Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
Returning 401 for a forbidden action | Clients try to re-login instead of showing 'no access' | Use 403 when authenticated but not allowed |
| Copy-pasting role checks in handlers | Rules drift, hard to audit | Centralize in require_role / require_permission |
| Checking role but not ownership | Users act on other users' resources | Add an ownership check for user-owned data |
| Hardcoding role strings ('admin') | Typos silently deny/allow access | Use a Role / Permission enum |
| Doing authorization only on the frontend | Anyone can call the API directly | Always enforce on the server too |
| Trusting a role claim you never re-check | Stale/elevated permissions after a demotion | Verify against current data for sensitive actions |
What's Next
fastapi-jwtβ replace the fake token map with real tokens carrying the role claimfastapi-dependency-injectionβ the factory pattern behindrequire_rolefastapi-error-handlingβ turn these403s into your standard error schemafastapi-async-sqlalchemyβ load roles/permissions from the database instead of a dict
Recap: authentication says who you are (401), authorization says what you can do (403). Build reusable require_role / require_permission dependencies, prefer permissions for scale, guard whole routers, and never forget ownership checks. One place to define the rules β every endpoint just declares what it needs.