Skip to main content

Role-Based Access Control (RBAC) Guide

Role-Based Access Control (RBAC) assigns users to roles (admin, user, moderator), and routes check if a user's role is permitted. Unlike scopes (fine-grained, per-action), roles are coarse-grained categories. RBAC is ideal for internal teams and applications with clear user hierarchies (admin, editor, viewer). Scopes are better for third-party integrations where you need precise permission boundaries.

RBAC vs. Scopes vs. Attributes

ModelUnitExampleBest For
RBACRolesadmin, user, guestInternal teams, clear hierarchies
ScopesPermissionsusers:read, posts:writeOAuth2, delegated access
ABACAttributesage > 18, department = engComplex policies, flexible rules

Designing Roles and Permissions

Define roles and their permissions upfront:

# roles.py
from enum import Enum

class Role(str, Enum):
ADMIN = "admin" # Full access
MODERATOR = "moderator" # Manage content, users
EDITOR = "editor" # Create/edit own posts
VIEWER = "viewer" # Read-only

# Define role hierarchies (admin > moderator > editor > viewer)
ROLE_HIERARCHY = {
Role.ADMIN: [Role.ADMIN, Role.MODERATOR, Role.EDITOR, Role.VIEWER],
Role.MODERATOR: [Role.MODERATOR, Role.EDITOR, Role.VIEWER],
Role.EDITOR: [Role.EDITOR, Role.VIEWER],
Role.VIEWER: [Role.VIEWER]
}

# Map roles to permissions
ROLE_PERMISSIONS = {
Role.ADMIN: ["read_users", "write_users", "delete_users", "manage_roles"],
Role.MODERATOR: ["read_users", "manage_content", "moderate"],
Role.EDITOR: ["write_posts", "read_posts"],
Role.VIEWER: ["read_posts"]
}

Storing Roles in the Database

Add a roles field to your user model:

# models.py
from sqlalchemy import Column, String, List
from enum import Enum

class UserRole(str, Enum):
ADMIN = "admin"
MODERATOR = "moderator"
EDITOR = "editor"
VIEWER = "viewer"

class User(Base):
__tablename__ = "users"

id = Column(String, primary_key=True)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True)
hashed_password = Column(String)
roles = Column(String) # Comma-separated: "admin,moderator"
is_active = Column(Boolean, default=True)

def get_roles(self) -> list[str]:
"""Return list of user's roles."""
return [r.strip() for r in self.roles.split(",")] if self.roles else []

def set_roles(self, roles: list[str]):
"""Set user's roles."""
self.roles = ",".join(roles)

def has_role(self, role: str) -> bool:
"""Check if user has a specific role."""
return role in self.get_roles()

def has_any_role(self, roles: list[str]) -> bool:
"""Check if user has any of the specified roles."""
return any(r in self.get_roles() for r in roles)

Embedding Roles in JWT Tokens

Include roles in the JWT payload:

# security.py
from jose import jwt
from datetime import datetime, timedelta
from roles import Role

def create_access_token(
data: dict,
roles: list[str] | None = None,
expires_delta: timedelta | None = None
) -> str:
"""Create JWT with roles."""
to_encode = data.copy()

if roles:
to_encode["roles"] = roles
else:
to_encode["roles"] = []

if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)

to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

Update login to include roles:

# main.py
@app.post("/login", response_model=TokenResponse)
async def login(credentials: LoginRequest, db: Session = Depends(get_db)):
"""Login and issue token with roles."""
user = db.query(User).filter(User.username == credentials.username).first()

if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")

roles = user.get_roles()
token = create_access_token(
data={"sub": user.username, "email": user.email},
roles=roles
)

return {"access_token": token, "token_type": "bearer"}

Checking Roles on Protected Routes

Create a dependency that validates roles:

# security.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer_scheme = HTTPBearer()

def decode_token(token: str) -> dict | None:
"""Decode and validate JWT."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.JWTError:
return None

async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)
) -> dict:
"""Extract user info from token."""
token = credentials.credentials
payload = decode_token(token)

if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)

return {
"username": payload.get("sub"),
"roles": payload.get("roles", [])
}

async def require_role(
required_roles: list[str],
current_user: dict = Depends(get_current_user)
) -> dict:
"""Dependency to check if user has required role."""
user_roles = current_user.get("roles", [])

if not any(role in user_roles for role in required_roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of roles: {', '.join(required_roles)}"
)

return current_user

Use it on protected routes:

# main.py
from functools import partial

@app.get("/admin/users")
async def list_users(
current_user: dict = Depends(partial(require_role, ["admin"]))
):
"""List all users. Admin only."""
return {"users": [...]}

@app.post("/posts")
async def create_post(
content: str,
current_user: dict = Depends(partial(require_role, ["admin", "editor"]))
):
"""Create post. Requires admin or editor role."""
return {"post_id": "...", "author": current_user["username"]}

Role Hierarchy and Inheritance

Simplify permission checking using role hierarchies:

# security.py
from roles import ROLE_HIERARCHY

def has_required_role(user_roles: list[str], required_roles: list[str]) -> bool:
"""Check if user has required role, respecting hierarchy."""
for user_role in user_roles:
# Get all roles below this user's role in hierarchy
inherited_roles = ROLE_HIERARCHY.get(user_role, [])
if any(req in inherited_roles for req in required_roles):
return True
return False

async def require_role_hierarchy(
required_roles: list[str],
current_user: dict = Depends(get_current_user)
) -> dict:
"""Check roles with hierarchy support."""
user_roles = current_user.get("roles", [])

if not has_required_role(user_roles, required_roles):
raise HTTPException(status_code=403, detail="Insufficient role")

return current_user

With hierarchy, an admin can perform any action a moderator or editor can:

@app.get("/moderate/comments")
async def get_pending_comments(
current_user: dict = Depends(
partial(require_role_hierarchy, ["moderator", "editor"])
)
):
"""Moderators and above can access."""
return {"comments": [...]}

Role Management Endpoints

Allow admins to assign/revoke roles:

# main.py
from pydantic import BaseModel

class AssignRoleRequest(BaseModel):
username: str
roles: list[str] # e.g., ["editor", "moderator"]

@app.post("/admin/assign-role")
async def assign_role(
request: AssignRoleRequest,
current_user: dict = Depends(partial(require_role, ["admin"])),
db: Session = Depends(get_db)
):
"""Assign roles to a user. Admin only."""
target_user = db.query(User).filter(User.username == request.username).first()

if not target_user:
raise HTTPException(status_code=404, detail="User not found")

# Validate roles
valid_roles = [r.value for r in Role]
if not all(role in valid_roles for role in request.roles):
raise HTTPException(status_code=400, detail="Invalid roles")

target_user.set_roles(request.roles)
db.commit()

return {"username": target_user.username, "roles": target_user.get_roles()}

@app.get("/admin/users/{username}/roles")
async def get_user_roles(
username: str,
current_user: dict = Depends(partial(require_role, ["admin", "moderator"])),
db: Session = Depends(get_db)
):
"""Fetch a user's roles."""
user = db.query(User).filter(User.username == username).first()

if not user:
raise HTTPException(status_code=404, detail="User not found")

return {"username": user.username, "roles": user.get_roles()}

Testing RBAC

# test_rbac.py
from fastapi.testclient import TestClient
from main import app
from security import create_access_token

client = TestClient(app)

def test_admin_endpoint_requires_admin_role():
"""Test that admin endpoint rejects non-admin users."""
token = create_access_token(
data={"sub": "alice"},
roles=["viewer"] # Only viewer role
)

response = client.get(
"/admin/users",
headers={"Authorization": f"Bearer {token}"}
)

assert response.status_code == 403

def test_admin_endpoint_accepts_admin():
"""Test that admin endpoint accepts admin users."""
token = create_access_token(
data={"sub": "admin_user"},
roles=["admin"]
)

response = client.get(
"/admin/users",
headers={"Authorization": f"Bearer {token}"}
)

assert response.status_code == 200

Key Takeaways

  • RBAC assigns users to roles; routes check if the user's role is allowed.
  • Embed roles in JWT tokens or look them up per-request from the database.
  • Role hierarchies (admin > moderator > editor > viewer) simplify permission checking.
  • Provide admin endpoints to assign/revoke roles without password changes.
  • Combine RBAC with scopes for fine-grained OAuth2 delegated access.

Frequently Asked Questions

Should I store roles in the JWT or database?

In JWT: fast but can't revoke immediately (wait for token expiry). In database: flexible and instant but requires a lookup per request. Hybrid: embed in JWT, but re-check database on sensitive operations or use short-lived tokens.

Can a user have multiple roles?

Yes. A user might be both editor and moderator. Check if they have any required role: if any(r in user_roles for r in required_roles).

How do I handle dynamic permissions outside predefined roles?

Use ABAC (Attribute-Based Access Control). Store custom policies in a database: if user.department == "finance" and action == "approve_expense". Check policies at runtime. Keycloak and Auth0 support this.

What's the difference between roles and scopes in my API?

Roles (admin, user) are coarse. Scopes (users:read, posts:write) are fine-grained. Use roles for internal teams; use scopes for public APIs and third-party integrations.

Further Reading