Site icon IT & Life Hacks Blog|Ideas for learning and practicing

Introduction to Multi-Tenant Design with FastAPI: Practical Patterns for Tenant Isolation, Authorization, Database Strategy, and Audit Logs

green snake

Photo by Pixabay on Pexels.com

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_id for 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, and separate 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_id in 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:

  1. Subdomain
    • Example: acme.example.com, foo.example.com
  2. Path
    • Example: /t/acme/projects, /t/foo/projects
  3. Token or header
    • Put tenant_id in a JWT claim
    • Or use a header like X-Tenant-ID

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_id or a membership list in the authentication token
  • However, a design in which tenant_id can 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.projects
  • tenant_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_id being 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_id
  • user_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_id exists, 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

  1. Start with shared tables and add tenant_id to all major tables
  2. Build a dependency that resolves tenant_id from JWT or session
  3. Add tenant_id conditions to all list and detail queries
  4. Add at least one test that prevents cross-tenant access

For engineers in small teams

  1. Inventory which models are tenant-owned
  2. Standardize tenant scoping in the repository layer
  3. Decide on the role model inside the tenant
  4. Include tenant_id in audit logs
  5. Reflect “how tenant selection works” in OpenAPI and error formats as well

For SaaS teams and startups

  1. Reevaluate the current isolation approach
  2. Separate concerns between data isolation, performance isolation, and legal requirements
  3. Strengthen boundary tests and audit logs
  4. If necessary, make a migration plan toward schema separation or DB separation
  5. Recheck billing, audit, and permission-change flows on a per-tenant basis

Reference links


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_id in 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_id condition 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.”

Exit mobile version