FastAPI Role-Based Access Control
Login proves who a person is. Access control decides what that person may do. Role-based access control, often shortened to RBAC, groups permissions into roles such as admin, editor, and viewer. Each user receives a role, and each route lists the roles it accepts. Think of an office building: a visitor badge opens the lobby, an employee badge opens the floors, and a manager badge opens the server room.
Two Questions Every API Asks
Authentication Who are you? ──▶ failure returns 401 Unauthorized
Authorization What may you do here? ──▶ failure returns 403 ForbiddenA 401 means the server does not know the user. A 403 means the server knows the user and refuses the action.
Designing Roles
Action viewer editor admin
───────────────── ────── ────── ─────
Read articles yes yes yes
Create articles no yes yes
Edit any article no yes yes
Delete articles no no yes
Manage users no no yesKeep the list short. Three or four roles cover most applications.
Defining Roles in Code
An enumeration prevents typos in role names.
from enum import Enum
class Role(str, Enum):
viewer = "viewer"
editor = "editor"
admin = "admin"The user model carries one role.
from pydantic import BaseModel
class User(BaseModel):
username: str
role: Role = Role.viewerGetting the Current User
The earlier topics on JWT tokens and protected routes build a get_current_user dependency. This example reuses that function, and the sample below fakes it for clarity.
from fastapi import Depends, Header
def get_current_user(x_user: str = Header()):
fake_users = {
"asha": User(username="asha", role=Role.admin),
"ravi": User(username="ravi", role=Role.editor),
"mina": User(username="mina", role=Role.viewer),
}
return fake_users[x_user]A Reusable Role Checker
A function that returns a dependency keeps role checks short and readable.
from fastapi import HTTPException
def require_roles(*allowed: Role):
def checker(user: User = Depends(get_current_user)):
if user.role not in allowed:
raise HTTPException(status_code=403, detail="You do not have permission")
return user
return checkerCalling require_roles(Role.admin) builds a checker that lets only admins through.
Protecting Routes
from fastapi import FastAPI
app = FastAPI()
@app.get("/articles")
def read_articles(user: User = Depends(get_current_user)):
return {"reader": user.username}
@app.post("/articles")
def create_article(user: User = Depends(require_roles(Role.editor, Role.admin))):
return {"message": f"Article created by {user.username}"}
@app.delete("/articles/{article_id}")
def delete_article(article_id: int, user: User = Depends(require_roles(Role.admin))):
return {"message": f"Article {article_id} deleted"}
Request Role Result
────────────────────────────── ─────── ───────────────
GET /articles viewer 200 OK
POST /articles viewer 403 Forbidden
POST /articles editor 200 OK
DELETE /articles/4 editor 403 Forbidden
DELETE /articles/4 admin 200 OKStoring Roles in JWT Tokens
Add the role to the token payload at login. The server reads the role from the verified token and skips a database lookup on each request.
token_data = {"sub": user.username, "role": user.role.value}Tokens stay valid until they expire, so a changed role takes effect after the next login. Keep token lifetimes short when roles change often.
Checking Ownership
Roles alone miss one common rule: users may edit their own records. Combine the role check with an ownership check inside the route.
if article.author != user.username and user.role != Role.admin:
raise HTTPException(status_code=403, detail="Not your article")Security Habits
- Enforce every rule on the server. Hiding a button in the frontend protects nothing.
- Grant the smallest role that gets the job done.
- Default new accounts to the lowest role.
- Log denied attempts for later review.
A Short Walkthrough
Follow one request through the system. Ravi, an editor, sends a delete request. The server reads his token and builds a user object with the editor role. The route's dependency checks the allowed list, which names only admins. The check fails, and the server answers with a 403 response before any delete code runs.
The design keeps rules close to the routes. A reader who opens the route file sees the permitted roles right in the function signature. Auditors and new teammates find the policy without hunting through scattered if statements.
Review roles regularly. Teams grow, features change, and a role that made sense a year ago may hold too much power today.
Key Points
- Authentication identifies users; authorization limits their actions.
- Roles group permissions, and enums keep role names consistent.
- A dependency factory such as
require_rolesguards routes in one line. - Return 401 for unknown users and 403 for forbidden actions.
- Combine roles with ownership checks for personal records.
