Introduction to RBAC/ABAC Authorization Management in FastAPI: A Practical Guide to Designing Secure Authorization with Roles, Attributes, and Policies
Summary
- Authentication is the mechanism for confirming who a person is, while authorization is the mechanism for deciding what that person is allowed to do. In real-world FastAPI development, authorization design has a major impact on both application security and maintainability.
- In small applications, it is practical to begin with RBAC (Role-Based Access Control). Grouping permissions into roles such as
admin,editor, andviewermakes both implementation and operations easier to understand. - However, in SaaS and multi-tenant environments, role-based logic alone often becomes insufficient. That is where ABAC (Attribute-Based Access Control) becomes important, by combining conditions such as tenant membership, ownership, publication status, and plan tier.
- In FastAPI, a clean approach is to use
DependsandSecurityto resolve the authenticated user, tenant, and target resource through dependency functions, and then centralize authorization decisions in policy functions. - In this article, we will walk through the differences between RBAC and ABAC, implementation patterns in FastAPI, how to combine them with multi-tenancy, how to connect them with audit logs, and how to test them, step by step and carefully.
Who Will Benefit from Reading This
Individual developers and learners
This is for people who have implemented login functionality, but are unsure how to write authorization rules such as “only administrators can edit” or “only the user themselves can delete their account.”
At first, something like if current_user.role == "admin" may seem to work fine, but once features grow, that style tends to become messy. This article will help you move from that stage toward a more organized structure.
Backend engineers in small teams
This is for people building admin panels, internal tools, or SaaS APIs with FastAPI and feeling that “role checks are scattered everywhere and feel risky” or “it is hard to see the impact of permission changes.”
The structure here is designed to answer practical questions such as how far RBAC alone can take you, when ABAC should be introduced, and how to divide responsibilities between service layers and dependency functions.
SaaS teams and startups
This is for teams in multi-tenant environments where tenant-level roles, ownership restrictions, plan-based feature limits, audit logs, and similar concerns have made it clear that “a simple admin check is no longer enough.”
If authorization rules are added in an ad hoc way, they become very difficult to fix later. Organizing early on where authorization decisions belong makes the system more resilient to future complexity.
Accessibility Evaluation
- The article starts by presenting the overall picture, then proceeds in the order of “RBAC basics,” “introducing ABAC,” “dependencies in FastAPI,” “policy functions,” “multi-tenancy,” and “testing and audit.” This makes it easy to understand even if you only read the sections you need.
- Technical terms are briefly explained when they first appear, and then the same wording is used consistently afterward. This is intended to reduce cognitive load.
- Code examples are kept short and split up so that each block shows only one responsibility. This makes them easier to follow visually.
- Headings are placed frequently so that the key point of each section is clear. The target level is approximately WCAG AA.
1. Why Is It Dangerous to Postpone Authorization?
In FastAPI, as in any framework, the early stage of application development often feels like it reaches a big milestone once “users can log in.”
But what really matters next is how you control what logged-in users are allowed to do.
A common early implementation looks like this:
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="forbidden")
At first, this feels sufficient, but as features increase, the following kinds of problems start to appear:
- The checks become inconsistent across routers
- Once roles other than
adminare added, there are too many places to update - Conditions such as “only the owner can edit” or “only an admin in the same tenant can do this” begin to mix together
- The permission names understood by the frontend and backend drift apart
- It becomes hard to leave clear audit logs showing why access was denied
In other words, authorization is not just an if statement. It is a design concern.
Once this part is organized properly, not only security but also code readability and ease of change improve substantially.
2. What Is RBAC? Managing Access Through Roles
RBAC stands for Role-Based Access Control, and it is the idea of deciding permissions based on a person’s role.
For example, in an article management application, you might think of it like this:
admin: can do everythingeditor: can create, edit, and publish articlesviewer: can only view
The nice thing about this approach is that it is easy for people to understand.
If you say “this person is an editor,” that is easy to explain within a team and easy to display in the UI.
2.1 Cases Where RBAC Works Well
RBAC is especially strong in situations like these:
- Internal admin panels
- Small to medium-sized management APIs
- Applications where permission patterns are relatively stable
- SaaS products where roles within each tenant are clearly defined
As a first authorization model, it is very approachable.
2.2 Situations Where RBAC Alone Stops Being Enough
However, in real work, conditions like the following often appear:
- Even an
editorcan only edit articles they created themselves - A
viewercan still see published content - Even an
admincannot view data from a tenant they do not belong to - Some features are unavailable in tenants on a free plan
As more of these checks based on attributes other than role appear, RBAC alone becomes harder to use.
That is where ABAC comes in.
3. What Is ABAC? Making Fine-Grained Decisions with Attributes
ABAC stands for Attribute-Based Access Control, and it is the idea of deciding authorization based on attributes.
Here, attributes can include information such as:
- User attributes
user_id,role,department,plan
- Resource attributes
owner_id,tenant_id,status,visibility
- Environment attributes
- time, IP address, client environment
- Tenant attributes
- subscribed plan, options, suspension state
For example, these are typical ABAC-style decisions:
- “Editing is allowed if the article’s
owner_idmatches the current user’suser_id” - “Export is allowed if the tenant’s
planisproor higher” - “A resource can be viewed even without login if it is marked
public”
So ABAC is not just about asking “what role does this person have?” but also “what is the relationship between this person and the target?”
3.1 RBAC and ABAC Are Not Opposed — They Are Usually Combined
In practice, RBAC and ABAC are often used together rather than choosing only one.
For example:
- Use RBAC to decide the broad outline
- A
viewercannot access update APIs
- A
- Use ABAC to decide the fine-grained conditions
- An
editorcan only edit projects in their own tenant
- An
This two-layer approach makes it easier to achieve both clarity and flexibility.
4. The Basic FastAPI Approach: Push Authorization into Dependency Functions and Policy Functions
In FastAPI, authorization logic is usually easier to understand when it is pushed into dependency functions and policy functions rather than written directly inside routers.
If we separate responsibilities, they look like this:
- Authentication dependency
- gets the current user
- Tenant dependency
- resolves the current
tenant_id
- resolves the current
- Resource dependency
- gets the target project or article
- Policy function
- decides whether “this user is allowed to perform this action”
With this structure, routers become easier to read because you can clearly see what is being used for authorization, and the code is easier to test as well.
5. A Base Model for Users, Tenants, and Roles
Let us start with the smallest model that still makes the concepts easy to understand.
# app/models/auth.py
from pydantic import BaseModel
class CurrentUser(BaseModel):
user_id: int
email: str
global_role: str | None = None
tenant_ids: list[int] = []
active_tenant_id: int | None = None
Here, we assume the following simple structure:
global_rolerepresents system-wide authority such as a super-admintenant_idsis the list of tenants the user belongs toactive_tenant_idis the currently selected tenant
Then we manage tenant-specific roles separately.
# app/models/membership.py
from pydantic import BaseModel
from typing import Literal
TenantRole = Literal["owner", "admin", "member", "viewer"]
class TenantMembership(BaseModel):
user_id: int
tenant_id: int
role: TenantRole
This allows us to think separately about “global administrators” and “roles inside each tenant.”
6. Resolve the Authenticated User and Tenant Through Dependencies
6.1 Return the current user
In practice, JWT is common, but here we simplify for explanation.
# app/deps/auth.py
from fastapi import Depends
from app.models.auth import CurrentUser
def get_current_user() -> CurrentUser:
return CurrentUser(
user_id=1,
email="hanako@example.com",
global_role=None,
tenant_ids=[10, 20],
active_tenant_id=10,
)
6.2 Resolve the current tenant_id
# app/deps/tenant.py
from fastapi import Depends, Header, HTTPException, status
from app.deps.auth import get_current_user
from app.models.auth import CurrentUser
def get_current_tenant_id(
current_user: CurrentUser = Depends(get_current_user),
x_tenant_id: int | None = Header(default=None),
) -> int:
tenant_id = x_tenant_id or current_user.active_tenant_id
if tenant_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tenant is not selected",
)
if tenant_id not in current_user.tenant_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="tenant access is forbidden",
)
return tenant_id
The important point here is: never trust a tenant_id from a header or path by itself.
Always check whether the user actually belongs to that tenant.
7. Minimal RBAC Implementation: Turn Role Checks into Dependencies
Let us begin with a simple case where RBAC alone is enough.
For example, suppose only tenant administrators are allowed to invite members.
7.1 Function to get the tenant role
# app/deps/membership.py
from fastapi import Depends, HTTPException, status
from app.deps.auth import get_current_user
from app.deps.tenant import get_current_tenant_id
from app.models.auth import CurrentUser
from app.models.membership import TenantMembership
def get_current_membership(
current_user: CurrentUser = Depends(get_current_user),
tenant_id: int = Depends(get_current_tenant_id),
) -> TenantMembership:
# In practice, fetch from the DB
if current_user.user_id == 1 and tenant_id == 10:
return TenantMembership(user_id=1, tenant_id=10, role="admin")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="membership not found",
)
7.2 Dependency requiring a specific role
# app/deps/permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.membership import get_current_membership
from app.models.membership import TenantMembership
def require_tenant_admin(
membership: TenantMembership = Depends(get_current_membership),
) -> TenantMembership:
if membership.role not in {"owner", "admin"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="admin role required",
)
return membership
7.3 Use it in the router
# app/api/v1/routers/members.py
from fastapi import APIRouter, Depends
from app.deps.permissions import require_tenant_admin
router = APIRouter(prefix="/members", tags=["members"])
@router.post("")
def invite_member(
membership = Depends(require_tenant_admin),
):
return {"status": "invited", "tenant_id": membership.tenant_id}
With this structure, the router clearly expresses the intent that “administrator permission is required.”
8. Basic ABAC Implementation: Decide Based on Ownership and Resource Attributes
Now let us move to a case where RBAC alone is not enough.
For example: even within the same tenant, members should only be able to edit projects they created themselves.
8.1 Example resource model
# app/models/project.py
from pydantic import BaseModel
from typing import Literal
class Project(BaseModel):
id: int
tenant_id: int
owner_id: int
name: str
visibility: Literal["private", "public"] = "private"
8.2 Dependency to fetch the target resource
# app/deps/project.py
from fastapi import Depends, HTTPException, status
from app.deps.tenant import get_current_tenant_id
from app.models.project import Project
def get_project(project_id: int, tenant_id: int = Depends(get_current_tenant_id)) -> Project:
# In practice, fetch from the DB with tenant_id as a filter
if project_id == 1 and tenant_id == 10:
return Project(id=1, tenant_id=10, owner_id=1, name="Project A", visibility="private")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="project not found",
)
The key point is that the resource is already fetched not by project_id alone, but together with tenant_id.
8.3 Decide in a policy function
# app/policies/project_policy.py
from app.models.auth import CurrentUser
from app.models.membership import TenantMembership
from app.models.project import Project
def can_edit_project(
current_user: CurrentUser,
membership: TenantMembership,
project: Project,
) -> bool:
if membership.role in {"owner", "admin"}:
return True
if membership.role == "member" and project.owner_id == current_user.user_id:
return True
return False
8.4 Wrap it as a dependency
# app/deps/permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.auth import get_current_user
from app.deps.membership import get_current_membership
from app.deps.project import get_project
from app.policies.project_policy import can_edit_project
def require_project_edit_permission(
current_user = Depends(get_current_user),
membership = Depends(get_current_membership),
project = Depends(get_project),
):
if not can_edit_project(current_user, membership, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="project edit is forbidden",
)
return project
In this form, the authorization logic itself stays as a pure function that is easy to test, while the FastAPI-dependent part remains thin.
9. Common Types of Attributes Used in ABAC
It becomes easier to design ABAC if you first organize the kinds of attributes that commonly appear in practice.
9.1 User attributes
user_idglobal_roleemail_verifiedis_activedepartmentplan
9.2 Tenant attributes
tenant_idplan(free/pro/enterprise)status(active/suspended)feature_flags
9.3 Resource attributes
owner_idtenant_idstatus(draft/published/archived)visibility(private/internal/public)
9.4 Environment attributes
- access time
- IP address
- region
- client type
You do not need to inspect all of these from the start, but it helps to keep this perspective: beyond role, what else should be checked to make access safe?
10. Treat Plan Restrictions as Part of Authorization Too
In SaaS, feature restrictions are often tied to subscription plans.
It is often cleaner to treat this as a kind of ABAC as well.
10.1 Decision based on tenant plan
# app/models/tenant.py
from pydantic import BaseModel
from typing import Literal
class Tenant(BaseModel):
id: int
name: str
plan: Literal["free", "pro", "enterprise"]
# app/policies/export_policy.py
from app.models.tenant import Tenant
from app.models.membership import TenantMembership
def can_export_csv(tenant: Tenant, membership: TenantMembership) -> bool:
if tenant.plan not in {"pro", "enterprise"}:
return False
if membership.role not in {"owner", "admin", "member"}:
return False
return True
This way, both role and plan are handled explicitly.
Later, when rules such as “only enterprise tenants get extended audit log retention” are added, the logic stays easier to reason about.
11. Do Not Put Too Much in Routers: Centralize Policies in a Service Layer or Dedicated Module
Once you start writing authorization as raw if statements in each router, the code becomes very hard to read a few months later.
A recommended division looks like this:
- Router
- lines up dependency functions
- Policy functions
- express authorization rules
- Service layer
- handles important business rules together with authorization
For example, “publishing a project” may require not only edit permission, but also a business rule such as “the project must not already be archived.”
In such a case, thinking about authorization together with the service layer is more natural than handling everything in the router.
12. Combine with Audit Logs: Record Denied Actions Too
This connects to the earlier audit log design topic as well, but denied authorization attempts are important evidence too.
Especially events like the following are worth auditing:
- A user tried to access data belonging to another tenant
- A general member attempted an action requiring admin privileges
- A feature unavailable under the current plan was called
- An API key tried to access a forbidden endpoint
For example, when authorization fails, you might record an audit event like this:
detail={
"reason": "role_not_allowed",
"required": ["owner", "admin"],
"actual": "viewer",
}
Records like this make customer support and security monitoring much easier.
13. Common Failure Patterns
13.1 Deciding everything with only an is_admin flag
This feels convenient at first, but it tends to collapse once tenant roles and ownership checks are introduced.
13.2 Fetching only by resource_id without checking tenant_id
This is one of the most dangerous mistakes in multi-tenant systems.
Always include tenant_id in the query condition.
13.3 Authorization logic scattered across routers
This makes it very hard to see the impact of changes.
It is better to move the logic into policy functions and dependencies.
13.4 Feeling satisfied with frontend-only visibility control
Hiding a button does not matter if the API can still be called directly.
Final authorization control must always happen in the backend.
13.5 Not documenting the authorization design
If role names and permissions exist only in spoken conversation, implementation and operations are likely to drift apart.
It is much safer to align them through OpenAPI, internal documentation, and admin UI terminology.
14. Testing Strategy: Protect Authorization on the Assumption That It Is Easy to Break
Authorization is an area that tends to break whenever new features are added.
So it is important not only to write success-case tests, but also to write solid tests for cases that should be denied.
14.1 The minimum tests you should want
admincan updateviewercannot updatemembercan update only what they own- Resources in another tenant cannot be accessed
- Export is not allowed on the free plan
- Audit logs are recorded on authorization failure
14.2 Example unit test for a policy function
# tests/test_project_policy.py
from app.models.auth import CurrentUser
from app.models.membership import TenantMembership
from app.models.project import Project
from app.policies.project_policy import can_edit_project
def test_admin_can_edit_any_project():
user = CurrentUser(user_id=1, email="a@example.com", tenant_ids=[10], active_tenant_id=10)
membership = TenantMembership(user_id=1, tenant_id=10, role="admin")
project = Project(id=1, tenant_id=10, owner_id=2, name="P", visibility="private")
assert can_edit_project(user, membership, project) is True
def test_member_can_edit_own_project_only():
user = CurrentUser(user_id=1, email="a@example.com", tenant_ids=[10], active_tenant_id=10)
membership = TenantMembership(user_id=1, tenant_id=10, role="member")
own_project = Project(id=1, tenant_id=10, owner_id=1, name="Own", visibility="private")
other_project = Project(id=2, tenant_id=10, owner_id=2, name="Other", visibility="private")
assert can_edit_project(user, membership, own_project) is True
assert can_edit_project(user, membership, other_project) is False
When the core authorization logic is kept as pure functions like this, it becomes very easy to test.
15. Also Think About Alignment with OpenAPI and the Frontend
Authorization is not only a backend concern.
The frontend also needs to know what the user is allowed to do.
Two common patterns are:
- Include roles or a list of permissions in the response from
GET /me - Include plan information and enabled feature flags in a tenant info API
For example:
{
"user_id": 1,
"tenant_id": 10,
"role": "admin",
"permissions": [
"project.read",
"project.create",
"project.update",
"member.invite"
]
}
A response like this makes UI control much easier on the frontend.
However, this information should only be used as a guide for display control. Final authorization decisions must always be made by the backend.
16. Adoption Roadmap
Individual developers and learners
- Start with RBAC
- Create dependency functions like
require_admin - Once ownership checks become necessary, move them into policy functions
- Add 403 tests for the main APIs
Engineers in small teams
- Create a table of current roles and permissions
- Review authorization logic currently written directly in routers
- Move it step by step into dependencies and policy functions
- Record denial reasons in audit logs
- Improve OpenAPI and frontend-facing
/meresponses
SaaS teams and startups
- Separate global roles from tenant-level roles
- Make
tenant_ida required condition in all major data access paths - Identify the ABAC attributes you need, such as ownership, plan, and status
- Centralize authorization policies in code
- Add contract tests and permission tests to CI to prevent breaking changes
Reference Links
- FastAPI Documentation
- FastAPI Security
- FastAPI Dependencies
- OWASP Authorization Cheat Sheet
- NIST ABAC Guide (useful for understanding the concept)
Conclusion
- RBAC is easy to start with and is a very practical first step for authorization design in FastAPI.
- However, once multi-tenancy, ownership rules, and plan-based limits enter the picture, ABAC becomes necessary.
- In FastAPI, a clean structure is to push authentication, tenant resolution, and resource resolution into dependency functions, and centralize the actual authorization decisions in policy functions. This improves clarity and testability.
- Authorization should be designed as a backend responsibility, not just display control, and cases that should be denied must also be protected by tests.
- Rather than trying to build the perfect system from the start, it is more realistic to first establish a pattern with RBAC and then add ABAC where needed.
A natural next article in this sequence would be something like “Designing Plans, Billing, and Feature Restrictions for SaaS” or “Design Patterns for Internal Admin APIs Built with FastAPI.”

