Multi-Tenant SaaS Checklist: Lessons from Five Products

A multi-tenant SaaS checklist from five products we run: tenant IDs, one enforcement layer, roles, entitlements, webhooks, jobs and cross-tenant tests.

white and blue glass walled building
Photo by Kenrick Baksh / Unsplash

A multi-tenant SaaS checklist starts with seven things on day one: a tenant ID on every tenant-owned row, isolation enforced in exactly one layer, roles inside each tenant, entitlements kept apart from billing, webhook handlers that tolerate duplicates, background jobs that carry their tenant, and tests that try to cross tenants. Adding any of them later costs far more.

TL;DR: Pick one tenancy model and enforce it in one place, either Postgres row-level security or a base query in your app. Keep plans, seats and entitlements separate. Treat every webhook as possibly late or repeated. Put the tenant ID in every job and log line. Then write tests that attack your own API with another tenant's IDs.

This list comes from five products we build and run: Pullsy, SigmaCPM, GymFast, NoteTube and Kraftr. Each solved part of the problem differently. The goal is that nothing on the list is missing when the second customer signs up.

What belongs on a multi-tenant SaaS checklist?

Ten items cover what we've needed across five products. The first seven belong in the first release because they shape the schema and every endpoint. The last three can follow a few weeks later, as long as the schema already allows them. Where another post goes deeper, we link to it.

Area Must have Where we've done it
Tenancy Tenant ID on every tenant table, enforced in one layer GymFast (Postgres RLS), SigmaCPM (organizations in DRF)
Roles Roles per tenant membership, checked on the server GymFast's six-level hierarchy, owner to member
Seats Who can log in, counted per tenant SigmaCPM
Entitlements What the tenant paid for, apart from the plan name Pullsy, SigmaCPM (per module)
Billing Idempotent webhooks plus a scheduled reconcile NoteTube (Stripe and RevenueCat)
Currency Amounts in minor units with the currency beside them Kraftr (six currencies), Pullsy (INR and USD)
Jobs Every job carries its tenant; no tenant starves the rest SigmaCPM, Pullsy
Isolation tests Tests that request other tenants' objects Pullsy
Support, export, deletion Audited support access; a tenant can leave with its data Design for it in the schema
Per-tenant logs Tenant ID in logs, traces and usage Design for it in the schema

Shared database, or one per customer?

For most products, one shared database with a tenant column is the right start. AWS calls this the pool model, where tenants share resources, and contrasts it with a silo model where each tenant gets dedicated ones (AWS SaaS Lens). All five of our products are pooled.

A database per customer sounds safer. Then you run the same migration fifty times, and one fails at 2 a.m. Pooled tenancy keeps one schema, one migration path and one backup job. For the rare customer whose contract demands separation, the same AWS page describes a mixed "bridge" model (see the FAQ).

Where should tenant isolation be enforced?

In exactly one layer that every request passes through. That can be the database, with Postgres row-level security, or the application, with a base query that every view inherits. Both work. A mix doesn't, because some endpoints filter in SQL, some in code, and one quietly forgets.

GymFast takes the database route. Its six-level role hierarchy, from owner down to member, is enforced with row-level security instead of app code, so a bug in any of its three apps can't return another gym's rows. SigmaCPM takes the application route: a multi-tenant Django REST Framework backend where every queryset starts from the caller's organization.

If you choose RLS, read the fine print on who bypasses it. The PostgreSQL docs say table owners normally bypass row security unless the table is set to FORCE ROW LEVEL SECURITY, and superusers and BYPASSRLS roles always do. Many apps connect as the role that owns the tables. Without FORCE, their policies never run.

alter table projects enable row level security;
alter table projects force row level security;

create policy tenant_isolation on projects
  using (org_id = current_setting('app.org_id')::uuid)
  with check (org_id = current_setting('app.org_id')::uuid);

The same page notes that a table with RLS enabled and no policy uses default deny, so a forgotten policy fails closed rather than open. That's the behaviour you want. The full Supabase version, with helper functions and role checks, lives in our Supabase RLS post. Choosing a platform first? The Supabase vs Firebase page compares how each one handles multi-tenant data.

Roles live on the membership, not the user

Tenants aren't flat: a gym has owners, managers, front-desk staff, trainers and members. Model a role as a property of the link between a user and a tenant, not of the user. One person can then own one workspace and be a viewer in another without a second account.

Check the role on the server, on every write. Hiding a button isn't access control. GymFast's hierarchy works because the database refuses the write, not because the member app lacks the screen.

Why keep plans, seats and entitlements apart?

Because they change for different reasons. A plan is what sales sells, with a name and a price. Seats are how many people can log in. Entitlements are which features or modules a tenant can use, and with what limits. Code should ask "can this tenant use tenders?", never "is this the Pro plan?".

Pullsy runs an entitlement service over per-seat and per-storefront plans, and the limits are enforced down to a database constraint. We did that so a limit still holds when a code path forgets to ask. SigmaCPM needed per-module entitlements for a different reason: one backend serves several products, including a sibling password-sharing product and its Chrome extension, so "what did this organization buy" is a list of modules, not a tier.

What does billing need in a multi-tenant SaaS?

It needs webhook handlers that expect repeats and disorder, plus a scheduled job that checks the result. Stripe's docs say an endpoint "might occasionally receive the same event more than once" and that Stripe doesn't guarantee delivery in the order events were generated (Stripe webhooks). Log event IDs, verify signatures, and fetch current state.

Then reconcile. NoteTube bills through Stripe on the web and RevenueCat in its apps, with a scheduled job that reconciles subscriptions between them. Webhooks get you most of the way; the reconcile job catches the rest before a customer emails about it. The two-store setup and its failure cases are in our RevenueCat and Stripe post.

Currency is the other early decision. Stripe's API expects amounts in the currency's minor unit, so 1000 means 10 USD, and some currencies have no decimals at all (Stripe currencies). Store integers, store the currency next to every amount, and don't convert from one base price at display time unless you mean to. Kraftr bills in six currencies through Razorpay; Pullsy prices its plans in INR and USD. If you're an Indian company choosing a gateway, Razorpay vs Stripe covers that decision.

How do background jobs stay inside one tenant?

Every job payload carries its tenant ID, and the worker sets the tenant context before it touches data. A job without a tenant is a bug, not a system task. The second rule is fairness: one tenant importing a million rows shouldn't delay everyone else's reminder emails.

Use per-tenant concurrency limits, or a separate queue for heavy work. Pullsy holds per-connection locks so one account never syncs twice at the same time. SigmaCPM runs its jobs on a Postgres-backed queue with leases, orphan reclaim and skip-locked selection, which we walk through in Postgres as a job queue.

How do you test that tenants can't see each other?

Write tests that act like an attacker who already has a valid login. OWASP ranks broken object level authorization first in its 2023 API Security Top 10: the user can reach the endpoint legitimately but swaps in an object ID that isn't theirs (OWASP API1:2023). Its advice is to test the authorization checks and not deploy when they fail.

Pullsy's multi-tenant isolation is covered by tests. Whatever your stack, the pattern looks like this:

  1. Create two tenants with a user at every role.
  2. Log in as tenant A and request tenant B's objects by ID on every endpoint: list, detail, update, delete.
  3. Try to create an object in tenant B by putting B's ID in the request body.
  4. Run tenant A's background jobs and confirm nothing of B's changes.
  5. Run the suite in CI on every pull request.
def test_tenant_a_cannot_read_tenant_b(client_a, project_b):
    r = client_a.get(f"/api/projects/{project_b.id}/")
    assert r.status_code == 404  # not 403: don't confirm it exists

They're dull tests to write. They also catch the bug that ends a SaaS company. Vector search needs its own version, because a missing filter there leaks text rather than a row; see tenant isolation in vector search.

Support access, export, deletion and per-tenant logs

These can follow the first release. If a tenant ID sits on every table, each is a few days of work. If it doesn't, each becomes archaeology.

Support access. Someone on your team will need to see a customer's data to answer a ticket. Build a support role that can view one tenant, with an audit entry for every view, instead of engineers querying production by hand.

Export and deletion. Customers ask for their data, and some leave. Plan an export of everything a tenant owns in a format they can open, and a deletion that removes rows, files, search vectors and caches, with a record that it happened.

Per-tenant observability. Put the tenant ID in logs, traces and error reports, so "it's slow" from one customer filters to their requests in seconds. Record usage per tenant too, for billing.

Which items can wait until after launch?

Fewer than you'd hope. Tenancy, roles, entitlements, webhook handling, tenant-aware jobs and isolation tests all touch the schema or every endpoint, so they go in first. Support tooling, export, deletion and per-tenant dashboards can follow within weeks of launch, because they read data that's already tagged.

What's the cheapest mistake to avoid? Shipping a single-tenant schema "for now". Skipping tenancy to launch a week sooner is the false economy we see most often, because the retrofit touches every query you've written since. Our MVP cost guide covers what a SaaS MVP with these items built in costs and how long it takes.

FAQ

How do you turn a single-customer app into a multi-tenant one?

Add a tenants table, put a tenant ID on every tenant-owned table, and backfill existing rows to one tenant. Then move every query behind the single enforcement layer, add memberships with roles, and write cross-tenant tests before onboarding a second customer. Expect most of the effort in queries and background jobs.

Should tenant and record IDs be UUIDs or sequential integers?

UUIDs are the safer default for anything exposed in a URL. OWASP recommends random, unpredictable IDs because sequential integers make it trivial to guess another tenant's records. Random IDs don't replace authorization checks, though. They only make a missing check harder to find, so the cross-tenant tests still matter.

Can one person belong to several tenants?

Yes, and the schema should allow it from the start. Store a membership row linking a user to a tenant with a role on it. The same login can then be an owner in one workspace and a viewer in another, and switching workspace just changes which membership the server reads.

What if one large customer demands its own database?

Treat it as an exception, not a new architecture. AWS calls this a bridge model, where some tenants or services are siloed while the rest stay pooled. Keep the same code and migrations, point that tenant at its own database, and price the extra operations work into their contract.

Building a SaaS product, or adding tenants to one that was built for a single customer? See our SaaS development service, then get a written plan with scope, timeline and a number.