back to writing
· 7 min

RBAC in FastAPI that survives production

Dependency-level guards, scope tables, and the mistakes I fixed in Support CRM.

Guards belong in dependencies, not handlers

The mistake I kept making in Support CRM was checking roles inside each endpoint: if user.role != admin: raise 403. It works until you have 14 endpoints and three role tiers, then every new route is a chance to forget a check.

The fix is dependency-level guards. A single require_role('agent', 'admin') dependency resolves the user, validates scopes, and either returns a typed User or raises 401/403 before the handler runs. Handlers stay thin and every route declares its policy in its signature.

async def require_role(*roles: str):
    async def guard(user: User = Depends(get_current_user)):
        if user.role not in roles:
            raise HTTPException(status_code=403, detail="Insufficient scope")
        return user
    return guard

Model scopes as data, not if-statements

Role names drift. What stays stable is the scope table: which role can do what on which resource. I keep a small matrix (role x action x resource) and test it directly, so adding a supervisor tier touches data, not branching logic.

Audit everything the guard rejects. Half my 403 investigations in production were mis-assigned roles, not attacks — the rejection log told me in seconds.

Checklist before you ship

Access and refresh tokens are short-lived and rotated; password hashing is bcrypt; every 403 is logged with user, route, and required scope; dashboard queries hit indexed status and assignee columns so the guard never becomes the slow part.

Building something that needs this rigor?

Send a 2-line brief — I reply with architecture thoughts, not fluff.

Hire me