SaaS Infrastructure

Scaling Multi-Tenant SaaS Databases: Row-Level Security & Tenant Isolation in Supabase

Scaling Multi-Tenant SaaS Databases: Row-Level Security & Tenant Isolation in Supabase

When launching a Software-as-a-Service (SaaS) platform, one of the most critical design decisions is how you isolate tenant data. A tenant represents a customer account or organization that uses your platform. If tenant data leaks to another tenant, it represents a catastrophic security failure that can ruin your company's reputation.

For startups and scaling enterprises, managing separate databases for every tenant introduces massive operational complexity and high costs. The standard alternative is a shared database, shared schema architecture. In this setup, all tenant records reside in the same tables, distinguished by a `tenant_id` column.

To make this shared setup secure, PostgreSQL provides Row-Level Security (RLS). Let's walk through how to configure RLS in Supabase to enforce robust, transparent tenant isolation.

---

The Core Concept: Database-Level Firewalls

In traditional applications, developers wrote query filters manually to isolate data: `SELECT * FROM projects WHERE tenant_id = 'current_tenant_id'`. If a developer forgot to append `WHERE tenant_id = ...` on a single query, the application would accidentally expose all tenants' records.

PostgreSQL RLS shifts security checks from the application layer to the database layer. Once enabled, PostgreSQL intercepts every incoming query, automatically applying filters defined by security policies. Even if a developer writes `SELECT * FROM projects`, PostgreSQL restricts the result set to only rows the authenticated user is permitted to see.

+-------------------------------------------------------------+
|                     Next.js API Route                       |
|         Executes: SELECT * FROM projects;                   |
+-------------------------------------------------------------+
                              |
                              v (Requests with JWT token)
+-------------------------------------------------------------+
|                     Supabase Engine                         |
|         Extracts tenant_id from user JWT session            |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
|                     PostgreSQL Database                     |
|  RLS Policy: tenant_id = auth.jwt() -> 'tenant_id'          |
|  Filters & returns ONLY the current tenant's rows.          |
+-------------------------------------------------------------+

---

Step-by-Step Implementation

Step 1: Design the Database Schema

Let's design a simple structure where users belong to a tenant (organization) and manage projects. We define a `tenants` table and a `projects` table where every project contains a `tenant_id` foreign key.

-- Create tenants table
create table tenants (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null

-- Create projects table referencing tenant create table projects ( id uuid default gen_random_uuid() primary key, tenant_id uuid references tenants(id) on delete cascade not null, name text not null, description text, created_at timestamp with time zone default timezone('utc'::text, now()) not null ); ```

Step 2: Enable Row-Level Security

By default, PostgreSQL tables do not filter rows. You must explicitly enable RLS:

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

Step 3: Map User JWTs to Tenants

In a multi-tenant application, user logins are authenticated via Supabase Auth. To map users to their tenant, we can store the `tenant_id` inside the user's JWT metadata (App Metadata). This avoids running lookup queries on every single database request.

When a user signs up or is invited, we write a trigger or set their app metadata via the admin API:

{
  "tenant_id": "8f3b9c02-47d1-4e89-bd60-4ea7df20bce1"
}

Now, we can access this value inside our PostgreSQL policies using Supabase's helper function `auth.jwt()`:

-- Helper to extract tenant_id from JWT app_metadata
create or replace function auth.tenant_id()
returns uuid as $$
  select coalesce(
    (nullif(current_setting('request.jwt.claims', true), ''))::jsonb -> 'app_metadata' ->> 'tenant_id',
    null
  )::uuid;
$$ language sql stable;

Step 4: Write Row-Level Security Policies

Now, we define policies on our tables that ensure users can only see rows matching their JWT's `tenant_id`:

-- Policy for projects table
create policy "Users can perform actions on their tenant's projects"
on projects
for all -- covers select, insert, update, and delete
using (tenant_id = auth.tenant_id())
with check (tenant_id = auth.tenant_id());

* `USING` clause: Applies to existing records (filters `SELECT`, `UPDATE`, `DELETE` operations). * `WITH CHECK` clause: Validates newly created or modified records (prevents inserting a project with another tenant's ID).

---

Querying from Next.js App Router

By implementing RLS, our React Server Components or Server Actions don't need to specify tenant filters manually. Using the Supabase client associated with the user's session, queries automatically inherit RLS filters:

export async function TenantProjectsList() { const supabase = await createClient(); // This query automatically returns ONLY projects belonging to the logged-in user's tenant const { data: projects, error } = await supabase .from('projects') .select('*');

if (error) { return <p>Error loading projects: {error.message}</p>; }

return ( <ul className="space-y-4"> {projects.map((project) => ( <li key={project.id} className="p-4 border rounded-xl shadow-sm"> <h3 className="font-semibold">{project.name}</h3> <p className="text-sm text-muted-foreground">{project.description}</p> </li> ))} </ul> ); } ```

---

Best Practices for Enterprise Multi-Tenancy

  1. Always enforce foreign keys: Ensure `tenant_id` columns carry foreign key constraints targeting your `tenants` table to prevent orphaned data.
  2. Avoid cross-tenant updates: Use `WITH CHECK` clauses on policies to block users from moving resources to external tenants.
  3. Audit Logs: When modifying critical tenant tables, configure database triggers to write modifications to a separate `audit_logs` table for compliance reports.
  4. Index tenant_id columns: As your databases grow, speed up query resolutions by adding database indexes:
  5. `CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);`

Database-level isolation ensures that security is baked directly into your data architecture, keeping your SaaS system fast, scalable, and completely secure.

Technologies covered in this article:

SupabasePostgreSQLNext.jsTypeScript

Frequently Asked Questions

What is PostgreSQL Row-Level Security (RLS)?

Row-Level Security is a PostgreSQL feature that acts as a query-level firewall, ensuring users can only read or write rows that meet specific policy checks.

Does Supabase support multi-tenant isolation out-of-the-box?

Yes, Supabase leverages PostgreSQL policies and JWT user metadata to enforce strict RLS rules natively.