---
title: "Tutorial: Build a multi-tenant SaaS dashboard"
slug: tutorial-multi-tenant
section: guides
order: 74
level: advanced
description: "Spin up a SaaS dashboard where every customer is on their own subdomain (acme.example.com, contoso.example.com), querysets are auto-scoped to the right tenant, and one user can never see another tenant's data — even if they tamper with URLs. Uses TenantScopedMixin for the hard part: defense against accidental leaks."
---

# 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/` and
  `contoso.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=999` to see contoso's account 999.

| You'll learn | Documented in |
|---|---|
| `SubdomainResolver` setup | [Multi-Tenant](/guides/multi-tenant/) |
| `TenantScopedMixin` + `self.tenant` + `self.tenant_queryset(Model)` | [Multi-Tenant](/guides/multi-tenant/) |
| Per-tenant state backend isolation (Redis key prefix) | [Multi-Tenant](/guides/multi-tenant/) |
| Three subtle leak vectors most homegrown impls hit | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/), [authentication
> tutorial](/guides/tutorial-authentication/), 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:

```python
# 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:

```bash
# /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

```python
# 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:

1. **Every tenant-scoped model has `tenant = ForeignKey(Tenant)`.**
   This is the field `tenant_queryset()` filters on.
2. **`objects = TenantManager()`** swaps Django's default manager
   for one that REQUIRES `.for_tenant(t)` before returning rows.
   Trying `Account.objects.all()` raises a clear "specify a
   tenant" error in dev. Production-safe.

---

## Step 3 — The view, with TenantScopedMixin

```python
# 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:

1. **`request.user.is_authenticated`** — page-level auth (covered
   in [tutorial #10](/guides/tutorial-authentication/)).
2. **Membership check** — is THIS user a member of THIS tenant?
   Without it, alice@acme could log into `contoso.localhost` and
   see Contoso's data. The framework can't infer this rule — it
   depends on your `Membership` model.
3. **`self.tenant_queryset(Account)`** — every DB query for
   tenant-scoped models goes through this. There is no
   `Account.objects.all()` path that would skip the filter.

---

## Step 4 — The template

```html
<!-- 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

```python
# 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

```python
# 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

```python
# 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`), and `SessionResolver` are all in
  [`djust.tenants.resolvers`](/guides/multi-tenant/). Pick the
  one that matches your URL scheme.
- **Per-tenant Redis keys:** the [Multi-Tenant
  guide](/guides/multi-tenant/) covers configuring
  `TenantAwareRedisBackend` so 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](/guides/tutorial-presence/) — the `presence_key`
  template can include `{tenant.slug}` so two tenants viewing
  the "same" doc URL never see each other's cursors.
- **Audit log:** wrap every `@action` in 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.
