Multi-tenant Django that fails closed
The failure mode
Multi-tenant B2B apps have one terrifying bug: a query without a tenant_id filter. It’s usually a new endpoint, or a report, or a debug shell. Run it, and suddenly Tenant A sees Tenant B’s rows. In production there is no second chance.
The fix, in Django
Row-level tenancy with three cooperating pieces, all of which fail closed.
1. A middleware that reads the JWT and attaches the tenant
class TenantMiddleware: def __init__(self, get_response): self.get_response = get_response
def __call__(self, request): # JWT-authenticated requests get a tenant claim; read it here. claim = getattr(request, "auth", None) request.tenant_id = claim.get("tenant") if claim else None _tenant_context.set(request.tenant_id) # ContextVar try: return self.get_response(request) finally: _tenant_context.set(None)The ContextVar makes the tenant ID visible deep in ORM code without passing it through every signature.
2. A manager that filters every query
class TenantScopedManager(models.Manager): def get_queryset(self): tid = _tenant_context.get() if tid is None: raise TenantNotSetError( "TenantScopedManager used with no tenant in context" ) return super().get_queryset().filter(tenant_id=tid)Every ORM read through Model.objects.whatever() gets filtered. A missing tenant doesn’t return zero rows, it raises. Silent empties hide bugs; loud exceptions surface them.
3. A save() that blocks mismatched inserts
class TenantScopedModel(models.Model): tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.PROTECT)
def save(self, *args, **kwargs): ctx_tenant = _tenant_context.get() if ctx_tenant is None: raise TenantNotSetError("Attempted save with no tenant context") if self.tenant_id not in (None, ctx_tenant): raise CrossTenantWriteError( f"Tenant mismatch: row={self.tenant_id}, ctx={ctx_tenant}" ) if self.tenant_id is None: self.tenant_id = ctx_tenant super().save(*args, **kwargs)
class Meta: abstract = TrueThree defenses: read, write, and “not set at all.” All raise by default.
The gotchas
Model.objects.all()in a management command. No middleware runs, no tenant is set, the manager raises. Fix: give management commands a helper that sets_tenant_contextexplicitly for the tenant they’re operating on.- Related manager bypass.
tenant_a_user.visits.all()works fine;Visit.objects.filter(user=tenant_a_user)works through the manager. Butuser.visits_set.all()goes through a reverse accessor that uses the default manager. The project fix: makeTenantScopedManagerthe default (objects = TenantScopedManager()), not an alternate.Meta.base_manager_name = "objects"if you’re on older Django. - Raw SQL.
Model.objects.raw(...)andconnection.cursor()bypass the manager. Lint rule + code review: no raw SQL in domain code. Exceptions go through an explicit helper that injects the tenant. - Admin. Django admin has its own querysets. Either hide the admin in prod or override
get_queryset()on eachModelAdmin. select_related/prefetch_related. Fine, they traverse FKs, each through its own manager, each filtered. As long as every model usesTenantScopedManager, the graph stays scoped.
Why it’s worth it
The default tenant_id filter is the most commonly forgotten WHERE clause in B2B SaaS. Making it automatic, and making its absence an exception, not a silent pass, turns a class of production incidents into a class of test failures. The tests catch it; the prod logs never have to.
I built this into a portfolio project (home-health-provider-skeleton) and the middleware + manager + model base class is ~100 lines total. Cheap to add, very cheap to live with.
See also
- Django Part 5, Authentication, custom user models and JWT
- Django Part 7, Advanced ORM, managers, Q, and query composition
- Django Part 10, Production, security headers and deployment hardening