FastAPI

FastAPI RBAC (Role-Based Access Control)

Thirdy Gayares
14 min read

🎯 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 403 responses

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, mapping

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

QuestionConceptFailure code
Who are you?Authentication (JWT, session)401 Unauthorized
Are you allowed?Authorization (RBAC)403 Forbidden
πŸ’‘
Ang importante dito: a valid token that isn't allowed to do something gets a 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:

app/main.py (naive β€” scattered checks)
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")
    ...
⚠️
Why this hurts: the rules are copy-pasted, inconsistent ("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.

app/rbac.py
from enum import StrEnum


class Role(StrEnum):
    ADMIN = "admin"
    MANAGER = "manager"
    USER = "user"
app/security.py
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.

app/security.py
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 checker
app/main.py
from 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"]}
βœ…
One source of truth: the role rule lives in 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.

app/rbac.py
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())
app/security.py
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 checker
app/main.py
from 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"]}
πŸ’‘
Why permissions win at scale: to change what a manager can do, you edit 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:

app/main.py
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)
πŸ’‘
Use 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.

app/main.py
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"]}
⚠️
Common security hole: checking the role but not ownership. "Any logged-in user can hit PATCH /profiles/{id}" + no ownership check = anyone edits anyone. Always pair role checks with an ownership check on user-owned resources.

Try It (curl)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
1Run the app
run.sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Test each role against a protected route
# 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"}
βœ…
You did it: role guards, permission guards, router-level protection, and ownership checks β€” all returning the right 401/403, with rules defined in one place.

Common Mistakes (and Fixes)

MistakeSymptomFix
Returning 401 for a forbidden actionClients try to re-login instead of showing 'no access'Use 403 when authenticated but not allowed
Copy-pasting role checks in handlersRules drift, hard to auditCentralize in require_role / require_permission
Checking role but not ownershipUsers act on other users' resourcesAdd an ownership check for user-owned data
Hardcoding role strings ('admin')Typos silently deny/allow accessUse a Role / Permission enum
Doing authorization only on the frontendAnyone can call the API directlyAlways enforce on the server too
Trusting a role claim you never re-checkStale/elevated permissions after a demotionVerify against current data for sensitive actions
πŸ’‘
Defense in depth: hide admin buttons in the UI and enforce every rule on the server. The frontend is a convenience; the API is the real security boundary.

What's Next

βœ…
πŸš€ Recommended next reads:
  • fastapi-jwt β€” replace the fake token map with real tokens carrying the role claim
  • fastapi-dependency-injection β€” the factory pattern behind require_role
  • fastapi-error-handling β€” turn these 403s into your standard error schema
  • fastapi-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.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.