Tutorial: Build a multi-tenant SaaS dashboard
Multi-tenancy is the single hardest invariant in a SaaS app: a user from Acme Corp must never, under any circumstances, see a single row that belongs to Contoso Ltd. The same code paths (dashboard, search, billing) serve both tenants, but every query has to filter by the current tenant and every state field has to isolate per-tenant.
Roll-your-own multi-tenant starts as a if request.user.tenant != obj.tenant: raise 403 sprinkle and ends as an audit nightmare —
one missed filter and a customer sees data they shouldn't. djust
ships TenantScopedMixin to make the safe pattern the default
and the unsafe pattern hard to write.
By the end of this tutorial you'll have:
- A SaaS dashboard at
acme.example.com/dashboard/andcontoso.example.com/dashboard/— same view, different data. - Subdomain-based tenant resolution — the framework reads the
request host, looks up the tenant, sets
self.tenant. - A
tenant_queryset()helper that auto-filters every model query by the current tenant. Forgetting to filter is impossible because there's no other way to query. - State isolation — alice@acme's session state can't leak to bob@contoso even if they hit the same in-memory state backend.
- Defense against URL tampering — alice can't load
/dashboard/?account_id=999to see contoso's account 999.
| You'll learn | Documented in |
|---|---|
SubdomainResolver setup | Multi-Tenant |
TenantScopedMixin + self.tenant + self.tenant_queryset(Model) | Multi-Tenant |
| Per-tenant state backend isolation (Redis key prefix) | Multi-Tenant |
| Three subtle leak vectors most homegrown impls hit | This tutorial |
Prerequisites: Quickstart, authentication tutorial, and a Django project with at least one model that should be tenant-scoped (an
Account,Project,Document, etc.). Multiple subdomains pointed at your dev server (covered in Step 1).
Step 1 — Tenant resolution + dev DNS
Configure subdomain-based resolution:
# settings.py
DJUST_TENANT_RESOLVER = "djust.tenants.resolvers.SubdomainResolver"
DJUST_TENANT_CONFIG = {
"default_tenant": "public", # what to use if subdomain matches none
"subdomain_resolver": {
"domain": "example.com",
"exclude_subdomains": ["www", "api", "admin"],
},
}
ALLOWED_HOSTS = [".example.com", ".localhost"]
For local dev, point a wildcard to localhost:
# /etc/hosts (or use dnsmasq for *.localhost on macOS / Linux)
127.0.0.1 acme.localhost
127.0.0.1 contoso.localhost
Visit acme.localhost:8000 and contoso.localhost:8000 — same
Django process, but the resolver gives each request a different
tenant.
Step 2 — The Tenant model + tenant-scoped models
# myapp/models.py
from django.conf import settings
from django.db import models
from djust.tenants.managers import TenantManager
class Tenant(models.Model):
slug = models.SlugField(unique=True) # "acme", "contoso"
name = models.CharField(max_length=200)
plan = models.CharField(max_length=20, default="free")
class Account(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE,
related_name="accounts")
name = models.CharField(max_length=200)
balance_cents = models.BigIntegerField(default=0)
objects = TenantManager()
class Meta:
# Optional: indexes that always include tenant_id make the
# auto-filter cheap.
indexes = [models.Index(fields=["tenant", "name"])]
class Membership(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
role = models.CharField(max_length=20, choices=[
("admin", "Admin"), ("member", "Member"),
])
class Meta:
unique_together = [("tenant", "user")]
Two important pieces:
- Every tenant-scoped model has
tenant = ForeignKey(Tenant). This is the fieldtenant_queryset()filters on. objects = TenantManager()swaps Django's default manager for one that REQUIRES.for_tenant(t)before returning rows. TryingAccount.objects.all()raises a clear "specify a tenant" error in dev. Production-safe.
Step 3 — The view, with TenantScopedMixin
# myapp/views.py
from django.shortcuts import redirect
from djust import LiveView, action, state
from djust.tenants.mixins import TenantScopedMixin
from .models import Account, Membership
class DashboardView(TenantScopedMixin, LiveView):
template_name = "dashboard.html"
accounts = state(default_factory=list)
selected_id = state(0)
def mount(self, request, **kwargs):
if not request.user.is_authenticated:
self.redirect("/login/")
return
# self.tenant is set by TenantScopedMixin's pre-mount hook.
# If the resolver couldn't find a tenant for this host, it
# falls back to default_tenant ("public") — you may want to
# 404 instead, depending on your model.
if self.tenant.slug == "public":
self.redirect("https://www.example.com/")
return
# Cross-check: is THIS user a member of THIS tenant? Without
# this check, a logged-in user could visit any tenant's
# subdomain and the per-tenant filter would happily render
# rows they're not entitled to (the rows wouldn't leak across
# tenants, but they'd see THIS tenant's rows).
if not Membership.objects.filter(
tenant=self.tenant, user=request.user,
).exists():
self.redirect("/no-access/")
return
self._refresh()
def _refresh(self):
# tenant_queryset auto-filters by self.tenant. Equivalent to
# Account.objects.for_tenant(self.tenant).
qs = self.tenant_queryset(Account).order_by("name")
self.accounts = [
{"id": a.id, "name": a.name, "balance_cents": a.balance_cents}
for a in qs
]
@action
def select_account(self, account_id: int = 0, **kwargs):
# Even with a tampered account_id, tenant_queryset filters
# the lookup — DoesNotExist for any account in another tenant.
try:
account = self.tenant_queryset(Account).get(pk=account_id)
except Account.DoesNotExist:
return # silently ignore — would log + alert in prod
self.selected_id = account.id
Three protective layers in this view:
request.user.is_authenticated— page-level auth (covered in tutorial #10).- Membership check — is THIS user a member of THIS tenant?
Without it, alice@acme could log into
contoso.localhostand see Contoso's data. The framework can't infer this rule — it depends on yourMembershipmodel. self.tenant_queryset(Account)— every DB query for tenant-scoped models goes through this. There is noAccount.objects.all()path that would skip the filter.
Step 4 — The template
<!-- myapp/templates/dashboard.html -->
<header class="dash-head">
<h1>{{ tenant.name }} dashboard</h1>
<p>{{ tenant.plan|capfirst }} plan</p>
</header>
<section class="dash-accounts">
<h2>Accounts</h2>
<ul>
{% dj-for account in accounts %}
<li class="account {% if account.id == selected_id %}is-selected{% endif %}">
<button type="button" dj-click="select_account"
data-account-id="{{ account.id }}">
{{ account.name }}
<span class="balance">${{ account.balance_cents|divisibleby:100 }}.00</span>
</button>
</li>
{% end-dj-for %}
</ul>
</section>
tenant is auto-injected into the template context by
TenantScopedMixin, so the dashboard header just renders
{{ tenant.name }} and gets the right value for whichever
subdomain the user came in on.
Three subtle leak vectors most homegrown impls hit
If you're writing your own multi-tenancy from scratch, these are the three places you'll leak data:
1. The "I forgot the filter" leak
# Looks fine. Returns ALL accounts across ALL tenants.
recent_accounts = Account.objects.order_by("-created_at")[:10]
The fix: TenantManager raises if you call .objects.all() /
.objects.filter(...) without going through .for_tenant(t).
Forgetting the filter is a runtime error in dev, not a silent
prod leak.
2. The "ID-based URL tampering" leak
# Looks like a normal handler. URL: /accounts/999/edit
def get_account(self, account_id: int = 0, **kwargs):
return Account.objects.get(pk=account_id) # ← any tenant's row
The fix: always go through self.tenant_queryset(Account).get(pk=...).
Tampering with the URL gets DoesNotExist, not a foreign tenant's
row.
3. The "shared cache key" leak
# Cache key without tenant prefix → tenants share cached values
@cache(key="dashboard_summary", ttl=60)
def get_summary(self):
return self.tenant_queryset(Account).aggregate(...)
The fix: include self.tenant.id in cache keys. djust's
TenantScopedMixin provides self.tenant_cache_key("dashboard_summary")
which prepends the tenant ID automatically.
What just happened, end to end
Browser Server
│ │
│ GET acme.example.com/dashboard/ │
│ ─────────────────────────────────► SubdomainResolver
│ │ → Tenant("acme")
│ │ DashboardView.mount()
│ │ self.tenant = Tenant("acme")
│ │ user authenticated? yes
│ │ member of acme? yes
│ │ tenant_queryset(Account)
│ │ → SELECT * WHERE tenant_id=1
│ │ self.accounts = [...]
│ ◄ render dashboard.html ──────────────│
│ │
│ │
Other browser Server
│ │
│ GET contoso.example.com/dashboard/ │
│ ─────────────────────────────────► SubdomainResolver
│ │ → Tenant("contoso")
│ │ DashboardView.mount()
│ │ self.tenant = Tenant("contoso")
│ │ ... membership check
│ │ tenant_queryset(Account)
│ │ → SELECT * WHERE tenant_id=2
│ │ (different rows, same view code)
│ ◄ render dashboard.html ──────────────│
Two tenants, one process, one set of view code. The tenant_queryset filter and the membership check are the only two new lines of guardrail code.
Where to go next
- Other resolvers: subdomain isn't the only option.
PathResolver(example.com/acme/),HeaderResolver(X-Tenant: acme), andSessionResolverare all indjust.tenants.resolvers. Pick the one that matches your URL scheme. - Per-tenant Redis keys: the Multi-Tenant
guide covers configuring
TenantAwareRedisBackendso each tenant's WebSocket session state gets a unique Redis key prefix. Critical when you scale out beyond one process. - Tenant-aware presence: combine with the presence
tutorial — the
presence_keytemplate can include{tenant.slug}so two tenants viewing the "same" doc URL never see each other's cursors. - Audit log: wrap every
@actionin a decorator that records(actor, tenant, action, target_id, timestamp)to a separate audit DB. The tenant context makes per-tenant audit trails trivial. - Cross-tenant admin views: for ops/support staff, build a
separate set of views that DON'T inherit
TenantScopedMixin— those rare paths explicitly opt out of tenant scoping. Mark them clearly and audit them on every PR.
The four-line shape — TenantScopedMixin + self.tenant +
self.tenant_queryset(Model) + the membership check — is the
entire daily-driver multi-tenant API. Once tenant_queryset is
muscle memory, "did I forget to filter?" stops being a question
you ask yourself.