DEV Community

Antigravity Dev Studio
Antigravity Dev Studio

Posted on

Building a Multi-Tenant FastAPI Micro-SaaS: Row-Level Security, Async SQLAlchemy 2.0 & Stripe Webhooks

Building a production-ready Multi-Tenant Micro-SaaS with Python in 2026 requires more than a simple pip install fastapi and a tutorial SQLite setup. When scaling past your first paying B2B customers, three architectural challenges immediately surface:

  1. Strict Multi-Tenant Isolation: Preventing data leakage between organizational tenants without managing hundreds of separate database instances.
  2. High-Throughput Async ORM: Leveraging modern Async SQLAlchemy 2.0 with connection pooling to prevent event loop bottlenecks.
  3. Bulletproof Stripe Billing: Idempotent webhook handling that prevents duplicate subscription grants or lost billing events under network jitter.

In this deep architectural breakdown, we will walk through the exact zero-compromise architecture used in production Micro-SaaS backends.


1. High-Level Multi-Tenant Architecture

There are three primary ways to handle multi-tenancy:

  • Database-per-tenant: Total isolation, but absurd operational overhead and connection pool exhaustion at scale.
  • Schema-per-tenant: Common in PostgreSQL, but creates heavy migration friction as tenant count climbs past 500.
  • Row-Level Partitioning with Tenant Context: Highly efficient, cost-effective ($0 on cloud serverless DBs like Supabase or Neon), and completely scalable when paired with async middleware and query filtering.

Here is the request flow:

[Client Request: Bearer JWT + X-Tenant-ID]
                   ↓
      [FastAPI Dependency Injection]
   (Decode Token, Validate Organization,
    Attach TenantContext to Request State)
                   ↓
  [Async SQLAlchemy 2.0 Session Factory]
   (Automatic Session Hook Injecting 
    `tenant_id` on SELECT/INSERT/UPDATE)
                   ↓
    [PostgreSQL Asyncpg Pool]
Enter fullscreen mode Exit fullscreen mode

2. Setting Up the Tenant Context & Dependency

We need a tamper-proof way to extract and enforce the tenant_id on every incoming request. We define an immutable TenantContext and inject it using FastAPI dependencies:

# app/core/security.py
from dataclasses import dataclass
from fastapi import Depends, HTTPException, Header, status
from jose import JWTError, jwt
from typing import Optional

SECRET_KEY = "your-production-jwt-secret-do-not-hardcode"
ALGORITHM = "HS256"

@dataclass(frozen=True)
class TenantContext:
    tenant_id: str
    user_id: str
    role: str

async def get_current_tenant(
    authorization: Optional[str] = Header(None),
    x_tenant_id: Optional[str] = Header(None)
) -> TenantContext:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing or malformed Authorization header"
        )

    token = authorization.split(" ")[1]
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id: str = payload.get("sub")
        org_memberships: dict = payload.get("organizations", {})
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials"
        )

    # Target tenant resolution
    target_tenant = x_tenant_id or payload.get("default_tenant_id")
    if not target_tenant or target_tenant not in org_memberships:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Access to specified tenant organization denied"
        )

    return TenantContext(
        tenant_id=target_tenant,
        user_id=user_id,
        role=org_memberships[target_tenant]
    )
Enter fullscreen mode Exit fullscreen mode

3. Async SQLAlchemy 2.0 with Automatic Tenant Filtering

In SQLAlchemy 2.0, explicit select() statements and typed models are standard. To guarantee zero tenant leakage, we create an abstract base model with tenant_id indexed and enforce it through an execution event hook:

# app/db/base.py
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Index
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class TenantModelMixin:
    """Enforces tenant isolation across all multi-tenant tables"""
    id: Mapped[str] = mapped_column(
        String(36), primary_key=True, default=lambda: str(uuid.uuid4())
    )
    tenant_id: Mapped[str] = mapped_column(String(36), index=True, nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), 
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc)
    )

# Composite index for lightning-fast multi-tenant queries
class CustomerWorkspace(Base, TenantModelMixin):
    __tablename__ = "customer_workspaces"

    name: Mapped[str] = mapped_column(String(120), nullable=False)
    settings: Mapped[str] = mapped_column(String(1000), default="{}")

    __table_args__ = (
        Index("idx_tenant_workspace", "tenant_id", "id"),
    )
Enter fullscreen mode Exit fullscreen mode

To automatically scope queries, you pass the TenantContext to your service repository:

# app/repositories/workspace_repo.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.base import CustomerWorkspace
from app.core.security import TenantContext

class WorkspaceRepository:
    def __init__(self, db: AsyncSession, context: TenantContext):
        self.db = db
        self.context = context

    async def get_workspace(self, workspace_id: str) -> CustomerWorkspace | None:
        query = (
            select(CustomerWorkspace)
            .where(
                CustomerWorkspace.id == workspace_id,
                CustomerWorkspace.tenant_id == self.context.tenant_id # Strict tenant boundary
            )
        )
        result = await self.db.execute(query)
        return result.scalar_one_or_none()

    async def create_workspace(self, name: str) -> CustomerWorkspace:
        workspace = CustomerWorkspace(
            name=name,
            tenant_id=self.context.tenant_id
        )
        self.db.add(workspace)
        await self.db.commit()
        await self.db.refresh(workspace)
        return workspace
Enter fullscreen mode Exit fullscreen mode

4. Bulletproof Stripe Webhook Idempotency

When selling SaaS subscriptions, Stripe webhooks can be retried multiple times due to temporary timeouts. If your endpoint isn't idempotent, you might trigger duplicate customer emails, provision extra resources, or corrupt accounting balances.

Here is the production-grade Stripe webhook handler:

# app/api/v1/billing.py
import stripe
from fastapi import APIRouter, Request, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.db.models import ProcessedWebhookEvent

router = APIRouter(prefix="/billing", tags=["Billing"])
STRIPE_WEBHOOK_SECRET = "whsec_your_stripe_signing_secret"

@router.post("/webhook")
async def stripe_webhook(request: Request, db: AsyncSession = Depends(get_db)):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # 1. Idempotency Check
    event_id = event["id"]
    existing_event = await db.execute(
        select(ProcessedWebhookEvent).where(ProcessedWebhookEvent.event_id == event_id)
    )
    if existing_event.scalar_one_or_none():
        # Already processed successfully: return 200 immediately to acknowledge Stripe
        return {"status": "already_processed"}

    # 2. Process Business Events
    event_type = event["type"]
    data_object = event["data"]["object"]

    if event_type == "customer.subscription.created":
        await handle_subscription_created(data_object, db)
    elif event_type == "customer.subscription.updated":
        await handle_subscription_updated(data_object, db)
    elif event_type == "customer.subscription.deleted":
        await handle_subscription_cancelled(data_object, db)
    elif event_type == "invoice.payment_failed":
        await handle_payment_failed(data_object, db)

    # 3. Mark event as processed in atomic transaction
    db.add(ProcessedWebhookEvent(event_id=event_id, event_type=event_type))
    await db.commit()

    return {"status": "success"}
Enter fullscreen mode Exit fullscreen mode

5. Deployment & Production Checklist

Before pushing your FastAPI Micro-SaaS to production:

  1. Gunicorn / Uvicorn Workers: Use uvicorn.workers.UvicornWorker with (2 * CPU cores) + 1 worker count.
  2. Async Connection Pool Sizing: Set pool_size=20, max_overflow=10 on your Async Engine to prevent PostgreSQL too many clients errors.
  3. Structured Logging: Use structlog with JSON output and automatic correlation IDs per request to trace errors across micro-services.
  4. Automated Migrations: Run alembic upgrade head in your CI/CD pipeline before container cutover.

Complete Production Blueprint & Master Vault

Want the complete, production-tested boilerplate with complete Auth0/JWT auth, Stripe Checkout, Docker containerization, and automated test suites?

Happy hacking and building your autonomous software empire in 2026!

Top comments (0)