Introduction to Multi-Tenant Design with FastAPI: Practical Patterns for Tenant Isolation, Authorization, Database Strategy, and Audit Logs
Summary
- Multi-tenant design is an approach for safely handling multiple customer organizations or contract units within a single application.
- In FastAPI, the basic principle is to determine the
tenant_idfor each request based on the authenticated user, and to ensure that every data access path always includes that condition. - There are three main isolation strategies:
shared database / shared tables,shared database / per-tenant schema, andseparate database per tenant. Many teams start with shared tables, but depending on requirements, it may be necessary to increase the degree of isolation earlier. - The dangerous state is not “authentication is broken,” but rather “authentication works, yet data from another tenant is visible.” Tenant boundaries are protected more by authorization and query design than by authentication itself.
- This article organizes, step by step, how to handle
tenant_idin FastAPI, how to scope queries in SQLAlchemy, how to design permissions, how to build audit logs, how to test boundaries, and how to think about future separation strategies.
Who will benefit from reading this?
Individual developers and learners
This is useful if you want to build a SaaS-like admin panel or team-based features, but the difference between “user-based” and “organization-based” design is still a bit unclear.
It is especially helpful if you currently manage data only by user_id and are now starting to think, “I want to separate Company A and Company B.”
Backend engineers in small teams
This is aimed at people planning to handle multiple customers in a single FastAPI app and wondering, “At which layer should I deal with tenant_id?” or “How should I design the database?”
If authorization holes are found later, the damage can be serious, so this helps organize the ideas that should be understood early.
SaaS teams and startups
This is also for teams already operating in a multi-tenant environment, where permissions, auditability, billing, and the degree of data isolation are becoming design issues.
It can be used as a design review guide, including how to judge whether to stay with shared tables or move toward schema separation or database separation.
Accessibility Notes
- The article first organizes the concepts, then proceeds in the order of
authentication → tenant_id resolution → DB isolation strategy → authorization → implementation examples → audit → testing. - Technical terms are explained briefly when first introduced, and the same wording is used consistently later to make the flow easier to follow.
- Code examples are split into short blocks, with each block showing only one role.
- The target level is roughly AA.
1. What is multi-tenancy?
Multi-tenancy is a design in which a single application platform is shared by multiple customers or organizations, while keeping each tenant’s data and permission boundaries secure.
A tenant here may refer to units such as:
- A company
- A team
- A school
- A store group
- A customer organization defined by contract
The important point is that “user” and “tenant” are not the same thing.
- A user is an individual person
- A tenant is the organization they belong to
For example, a single user may belong to multiple tenants.
Because of that, it is necessary to design the system so that tenant_id is handled explicitly, not just user_id.
2. The first thing to decide: where tenant boundaries are represented
The first design decision in a multi-tenant system is how to represent “which tenant context this request is operating in.”
There are three representative methods:
- Subdomain
- Example:
acme.example.com,foo.example.com
- Example:
- Path
- Example:
/t/acme/projects,/t/foo/projects
- Example:
- Token or header
- Put
tenant_idin a JWT claim - Or use a header like
X-Tenant-ID
- Put
In practice, it helps to think about it like this:
- For browser-facing SaaS, explicitly representing the tenant in the subdomain or path is easier to understand in the UI as well
- For API-centric systems, it is often easier to include
tenant_idor a membership list in the authentication token - However, a design in which
tenant_idcan be freely specified by header alone is dangerous, so it must always be cross-checked against authentication information
3. Three database isolation strategies
Multi-tenant database design can broadly be divided into three strategies.
3.1 Shared database / shared tables
All tenant data is stored in the same tables, and each record has a tenant_id.
Example:
projects
- id
- tenant_id
- name
- created_at
Advantages
- Simple to implement
- Low operational cost
- Table additions and migrations can be handled all at once
Disadvantages
- If a query omits the tenant condition, data from another tenant can become visible
- Per-customer backup and deletion are cumbersome
- Weak against stronger isolation requirements
If you want to start small, this is often the most realistic approach.
However, forgetting the tenant_id condition is the single biggest risk.
3.2 Shared database / per-tenant schema
This method separates each tenant into its own schema within the same database.
Examples:
tenant_acme.projectstenant_foo.projects
Advantages
- Stronger isolation than shared tables
- Easier per-customer backup and data management
Disadvantages
- Migrations and management become somewhat more complex
- Operations become heavier as the number of tenants grows
3.3 Separate database per tenant
Each tenant has its own database.
Advantages
- Strongest isolation
- Easier to satisfy legal, contractual, or high-security requirements
- Easier customer-specific recovery and migration
Disadvantages
- High operational cost
- Harder connection switching and migrations
- Often overkill at an early stage
4. The basic FastAPI approach: resolve tenant_id in dependencies
In FastAPI, it is easy to understand the design if the tenant context is resolved through Depends and then passed into the router and service layers.
4.1 Get the current user
First, obtain the authenticated user.
# app/deps/auth.py
from fastapi import Depends, HTTPException, status
from app.models.auth import CurrentUser
def get_current_user() -> CurrentUser:
# In reality, verify JWT, etc.
return CurrentUser(user_id=1, tenant_ids=[10, 20], active_tenant_id=10)
4.2 Resolve the current tenant_id
Determine the tenant for this request from the token, path, or header, while verifying that the user actually belongs to it.
# 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
What matters here is that x_tenant_id is not trusted as-is.
You must always verify, “Does this user actually belong to that tenant?”
5. Always pass tenant_id in SQLAlchemy
With the shared-table approach, the basic rule is that all major tables should include tenant_id.
5.1 Model example
# app/models/project.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String
from app.db.base import Base
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
5.2 Always filter queries by tenant_id
In the service or repository layer, always include tenant_id in the conditions.
# app/repositories/project_repository.py
from sqlalchemy.orm import Session
from app.models.project import Project
class ProjectRepository:
def __init__(self, db: Session):
self.db = db
def list_by_tenant(self, tenant_id: int) -> list[Project]:
return (
self.db.query(Project)
.filter(Project.tenant_id == tenant_id)
.order_by(Project.id.desc())
.all()
)
def get_by_id(self, tenant_id: int, project_id: int) -> Project | None:
return (
self.db.query(Project)
.filter(Project.tenant_id == tenant_id, Project.id == project_id)
.first()
)
It is important not to search only by project_id.
Even if id is unique, preventing accidents where “a project from another tenant can be fetched” requires always treating it together with tenant_id.
6. In routers, lean toward “service calls that always include tenant_id”
The router should deal only with HTTP concerns, while tenant_id is passed explicitly into services and repositories.
# app/api/v1/routers/projects.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.deps.db import get_db
from app.deps.tenant import get_current_tenant_id
from app.repositories.project_repository import ProjectRepository
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("")
def list_projects(
tenant_id: int = Depends(get_current_tenant_id),
db: Session = Depends(get_db),
):
repo = ProjectRepository(db)
items = repo.list_by_tenant(tenant_id)
return items
The advantage of this design is that in code review, it is easy to visually confirm:
- Is
tenant_idbeing received? - Is it being passed into the DB access layer?
7. Role design inside a tenant: not everyone in the same tenant has the same permissions
In multi-tenant systems, you need to separate “tenant boundaries” from “permissions within the tenant.”
For example, even inside the same company A, there may be roles such as:
- Owner
- Admin
- Member
- Viewer
7.1 Have a membership table
A common design is to place a join table between users and tenants.
tenant_memberships
- user_id
- tenant_id
- role
7.2 Check permissions in dependencies or services
# app/deps/permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.auth import get_current_user
from app.deps.tenant import get_current_tenant_id
def require_tenant_admin(
current_user=Depends(get_current_user),
tenant_id: int = Depends(get_current_tenant_id),
):
membership = None # In reality, query DB by user_id and tenant_id
if membership is None or membership.role not in {"owner", "admin"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="admin role required",
)
In other words, check in two steps:
- Does the user belong to that tenant?
- What are they allowed to do inside that tenant?
8. Tenant-switching UI and token design
If a user belongs to multiple tenants, the UI needs to clearly show “which tenant is currently being viewed.”
There are two common patterns:
8.1 Include active_tenant_id in the token
Reflect the currently selected tenant_id into the JWT at login time or when switching tenants.
Advantages
- No need to add a header on every request
- API implementation is simpler
Disadvantages
- The token must be reissued every time the tenant is switched
8.2 Put the membership list in the token and let the client choose via header
Store something like tenant_ids=[...] in the token and let the request specify the current tenant via X-Tenant-ID.
Advantages
- Flexible switching
- Easier to handle across multiple tabs
Disadvantages
- Easy to create confusion if header usage is not standardized
- Membership checks are always required
Either is acceptable, but what matters is that the team uses one approach consistently.
9. Audit logs: record who did what in which tenant
In multi-tenant systems, audit logs are especially important for incident investigation and customer support.
At minimum, it is useful to record:
tenant_iduser_id- Operation performed
- Target resource
- Success/failure
- Timestamp
- Request ID
Here is a simple example.
# app/services/audit.py
import logging
logger = logging.getLogger("audit")
def log_audit(
tenant_id: int,
user_id: int,
action: str,
resource: str,
resource_id: int | None = None,
):
logger.info(
"audit event",
extra={
"tenant_id": tenant_id,
"user_id": user_id,
"action": action,
"resource": resource,
"resource_id": resource_id,
},
)
For example, operations like “deleted an invoice” or “changed a member’s role” are worth always recording in the audit log.
10. Testing strategy: the scariest bug is “data from another tenant is visible”
In multi-tenant systems, the top testing priority is not the happy path, but boundary testing.
10.1 Minimum tests you want
- A user in tenant A can only view tenant A’s data
- If a user in tenant A specifies tenant B’s
tenant_id, the API returns 403 - Even if a
project_idexists, if it belongs to another tenant, the API returns 404 or 403 - If the user lacks the required role inside the tenant, management operations are denied
10.2 Example API test
def test_user_cannot_access_other_tenant_project(client, token_for_tenant_a):
res = client.get(
"/projects/999",
headers={
"Authorization": f"Bearer {token_for_tenant_a}",
"X-Tenant-ID": "20", # other tenant
},
)
assert res.status_code in (403, 404)
Whether to return 404 or 403 depends on your design.
If you want to hide the very existence of the resource, return 404. If you want to make the permission problem explicit, return 403. Align this across the team.
11. Decision points with future scale in mind
Even if you start with shared tables, you should consider revisiting your separation strategy when you start seeing signs like these:
- One specific customer’s data volume becomes much larger than everyone else’s
- Per-customer backup or deletion requirements become stronger
- Legal or contractual requirements demand stronger isolation
- Performance problems at the tenant level are spilling into other tenants
- Dedicated customer environments are becoming a sales requirement
At that point, if tenant_id is already explicit everywhere and the service layer and audit logs are well organized, migration becomes much easier.
12. Reader-specific roadmap
For individual developers and learners
- Start with shared tables and add
tenant_idto all major tables - Build a dependency that resolves
tenant_idfrom JWT or session - Add
tenant_idconditions to all list and detail queries - Add at least one test that prevents cross-tenant access
For engineers in small teams
- Inventory which models are tenant-owned
- Standardize tenant scoping in the repository layer
- Decide on the role model inside the tenant
- Include
tenant_idin audit logs - Reflect “how tenant selection works” in OpenAPI and error formats as well
For SaaS teams and startups
- Reevaluate the current isolation approach
- Separate concerns between data isolation, performance isolation, and legal requirements
- Strengthen boundary tests and audit logs
- If necessary, make a migration plan toward schema separation or DB separation
- Recheck billing, audit, and permission-change flows on a per-tenant basis
Reference links
- FastAPI
- SQLAlchemy
- Helpful background on design
Conclusion
- The essence of multi-tenant design is not being satisfied with “the user is authenticated,” but continuing to make clear “which tenant context this request belongs to.”
- In FastAPI, resolving
tenant_idin dependencies and explicitly passing it into the service and repository layers is easy to understand and safe. - The shared-table approach is easy to start with, but forgetting the
tenant_idcondition is its biggest risk. That is why tests and audit logs are essential for protecting the boundary. - The practical approach is to start small, then build a foundation that can later evolve into schema separation or DB separation depending on future legal, performance, and customer requirements.
A natural continuation from this topic would be articles such as “audit log design in FastAPI,” “permission management with RBAC/ABAC,” or “billing and plan control for SaaS.”
