A Singapore SaaS founder contacted me in a panic. A customer had discovered they could see another customer's invoices by changing a number in the URL. Not a sophisticated attack — just curiosity and sequential IDs.

The fix took 2 days. The damage took months to recover from. One affected customer was a law firm. Their data included client privilege materials. The discovery triggered a formal complaint to the PDPC.

Multi-tenancy is the architectural pattern that makes SaaS scalable. It's also the architectural pattern where mistakes produce the most severe security incidents. Getting it right from the start is non-negotiable.

What Multi-Tenancy Actually Means

A multi-tenant SaaS product serves multiple customers (tenants) from the same infrastructure. Acme Corp and Beta Pte Ltd both use your product, both store their data in your database, both run on your servers — but they must be completely isolated from each other.

Tenant isolation means:

  • Acme can never see Beta's data, even if there's a bug in application code
  • Actions by Acme users cannot affect Beta's data or operations
  • Billing, usage, and quotas are tracked separately per tenant

The implementation decision is how you enforce this isolation — at the application layer, the database layer, or both.

The Three Architecture Approaches

Separate database per tenant — Each customer gets their own database. Maximum isolation. Complex to manage at scale (hundreds of databases). Expensive. Only appropriate for enterprise contracts where customers demand database isolation for compliance reasons.

Separate schema per tenant — Each tenant gets their own schema within a shared database. Better management than separate databases, still strong isolation. Moderate complexity. Used by some platforms for compliance-sensitive industries.

Shared schema with org_id isolation — All tenants share the same tables. Every row in every tenant-data table has an org_id foreign key. Application and database-level security enforces that queries only return rows for the authenticated tenant. The most scalable and manageable approach. The standard choice for most SaaS products.

For 90% of Singapore SaaS products, shared schema with strong org_id isolation is the right choice. The security level achieved through row-level security (RLS) is equivalent to separate schemas for most threat models, with significantly lower operational complexity.

Row-Level Security in Supabase: The Implementation That Works

Supabase (Postgres) supports Row-Level Security natively. This means isolation is enforced at the database layer — independent of application code. Even if your application has a bug that accidentally tries to return all rows, the database refuses.

A basic RLS policy for a projects table:

-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see projects in their organisation
CREATE POLICY "tenant_isolation" ON projects
  FOR ALL
  USING (org_id = auth.jwt() ->> 'org_id');

The JWT (authentication token) carries the authenticated user's org_id. The RLS policy matches this against every row. No application code required to enforce this — the database does it automatically for every query.

This pattern should be applied to every table that contains tenant-specific data. The auth.jwt() function in Supabase extracts the claim from the authenticated user's JWT — meaning your application's auth setup must correctly include org_id in the JWT claims for this to work.

Developer reviewing database architecture
Row-Level Security in Postgres enforces tenant isolation at the database layer — providing a safety net even when application code has bugs.

The org_id Data Model

The data model for multi-tenancy follows a predictable pattern:

-- Organisations (tenants)
organisations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  plan TEXT NOT NULL DEFAULT 'free',
  created_at TIMESTAMPTZ DEFAULT now()
)

-- Users belong to one or more organisations
users (
  id UUID PRIMARY KEY, -- matches auth.uid()
  email TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
)

-- Join table for user-org membership
organisation_members (
  org_id UUID REFERENCES organisations(id),
  user_id UUID REFERENCES users(id),
  role TEXT NOT NULL DEFAULT 'member', -- 'owner', 'admin', 'member'
  PRIMARY KEY (org_id, user_id)
)

-- All tenant data tables include org_id
projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organisations(id),
  name TEXT NOT NULL,
  -- ... other fields
)

Never use sequential integer IDs for tenant-facing resources. Always use UUIDs. Sequential IDs are enumerable — an attacker can try /api/projects/1, /api/projects/2, etc. to discover all project IDs across all tenants. UUIDs are not guessable.

Common Multi-Tenancy Mistakes

Filtering in application code but not in the database — Application code filters by org_id. But if that code has a bug or is bypassed (a rare code path, an admin function), there's no safety net. Database-level RLS adds the safety net.

Not including org_id in the JWT — Your application needs to know the current user's org_id on every request. Include it in the JWT at authentication time and verify it's present before any data access.

Shared resources without scoping — File uploads, email templates, or configuration that should be per-tenant accidentally becoming global. Every resource that should be tenant-specific needs an org_id.

Admin routes that bypass tenant isolation — Internal admin panels for managing all tenants need separate, carefully controlled access — not just a flag that disables the tenant isolation logic.

Testing only with one tenant — Multi-tenancy bugs only appear when multiple tenants are present. Your development and QA environment needs at least two test tenants with separate data to catch isolation issues before production.

Scaling Multi-Tenancy

The shared schema approach scales well to thousands of tenants. When tenants reach very high data volumes, performance optimisation becomes important:

Partial indexes on org_id — Index all columns you query by filtered by org_id to avoid full table scans.

Connection pooling — At scale, each tenant's queries shouldn't starve others. PgBouncer or Supabase's built-in pooling manages this.

Archival strategy — Define when old tenant data moves to cheaper storage. This keeps the hot database performant for active tenants.

These are concerns for 10,000+ tenant scale. At early stages, focus on getting isolation correct and performance will be adequate.

PDPA Implications

Each tenant's data is personal data you're processing on their behalf. Your SaaS product is a data intermediary under PDPA. This means:

  • Your data processing agreement (DPA) with clients must be clear about how their data is stored and isolated
  • Tenant data must be deletable — you need a hard-delete path for when a tenant closes their account
  • Data residency — Supabase has a Singapore region. For clients with Singapore data residency requirements, your database must be in Singapore

At NICKTUNG, multi-tenancy architecture is how we build every SaaS product — with RLS from day one, UUID primary keys, and data deletion paths built into the schema before any application features are added.

Building a SaaS product in Singapore? Let's make sure the data architecture is right before you have paying customers relying on it.