HomeBlogSupabase Row Level Security for Multi-Tenant SaaS: A Practical Guide
SupabaseSecurityMulti-TenantDatabase

Supabase Row Level Security for Multi-Tenant SaaS: A Practical Guide

How to use Supabase RLS policies to build secure multi-tenant SaaS applications. Covers tenant isolation, organization-based access, role hierarchies, and performance optimization.

SaaSInMinutes
9 min read

Why RLS Is Non-Negotiable for SaaS

Every multi-tenant SaaS has one critical requirement: users must never see each other's data. A single data leak can destroy trust permanently.

Traditional approaches put this burden on application code — every query includes a WHERE user_id = ? clause. But this is fragile. One missed filter, one new endpoint that forgets the check, and you have a data breach.

Supabase Row Level Security moves this guarantee to the database layer. Even if your application code has a bug, the database itself enforces tenant isolation. This guide assumes you've already wired up Supabase auth — if not, start with our Next.js + Supabase authentication guide.

How RLS Works in Supabase

When you enable RLS on a table, PostgreSQL denies all access by default. You then create policies that define who can do what.

Every policy has access to auth.uid() — the ID of the currently authenticated user, extracted from the JWT that Supabase passes with every request.

Enabling RLS

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

After this, no queries will return results until you create policies.

Basic User Isolation

The simplest pattern — users can only access their own rows:

-- Users can read their own projects
CREATE POLICY "Users read own projects"
  ON projects FOR SELECT
  USING (user_id = auth.uid());

-- Users can insert their own projects
CREATE POLICY "Users insert own projects"
  ON projects FOR INSERT
  WITH CHECK (user_id = auth.uid());

-- Users can update their own projects
CREATE POLICY "Users update own projects"
  ON projects FOR UPDATE
  USING (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());

-- Users can delete their own projects
CREATE POLICY "Users delete own projects"
  ON projects FOR DELETE
  USING (user_id = auth.uid());

The USING clause filters which rows are visible. The WITH CHECK clause validates new or modified rows.

Organization-Based Multi-Tenancy

Most B2B SaaS products need organization-level access — multiple users sharing the same data within a team.

Schema Design

CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(user_id, organization_id)
);

CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

Organization Access Policies

-- Enable RLS on all tables
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Users can see organizations they belong to
CREATE POLICY "Members can view organization"
  ON organizations FOR SELECT
  USING (
    id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid()
    )
  );

-- Users can see projects in their organizations
CREATE POLICY "Members can view projects"
  ON projects FOR SELECT
  USING (
    organization_id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid()
    )
  );

Role-Based Access Within Organizations

Not everyone in an organization should have the same permissions:

-- Only admins and owners can create projects
CREATE POLICY "Admins can create projects"
  ON projects FOR INSERT
  WITH CHECK (
    organization_id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid()
      AND role IN ('owner', 'admin')
    )
  );

-- Only owners can delete projects
CREATE POLICY "Owners can delete projects"
  ON projects FOR DELETE
  USING (
    organization_id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid()
      AND role = 'owner'
    )
  );

Performance Optimization

RLS policies add a subquery to every database operation. On large tables, this can become a bottleneck.

Index the Right Columns

Every column referenced in an RLS policy needs an index:

-- Critical for organization-based policies
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(organization_id);
CREATE INDEX idx_memberships_user_org ON memberships(user_id, organization_id);
CREATE INDEX idx_projects_org_id ON projects(organization_id);

Use Security Definer Functions

For complex policies, wrap the logic in a function. PostgreSQL can cache the function result within a transaction:

CREATE OR REPLACE FUNCTION get_user_org_ids()
RETURNS SETOF UUID
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT organization_id FROM memberships
  WHERE user_id = auth.uid();
$$;

-- Simpler, faster policy
CREATE POLICY "Members can view projects"
  ON projects FOR SELECT
  USING (organization_id IN (SELECT get_user_org_ids()));

The STABLE marker tells PostgreSQL the function returns the same result within a single transaction, enabling caching.

Measure the Impact

Use EXPLAIN ANALYZE to check policy overhead:

EXPLAIN ANALYZE SELECT * FROM projects;

Compare execution time with and without RLS enabled. On a properly indexed table with 100K rows, the overhead should be under 2ms.

RLS is one of dozens of things you'll wire up. SaaSInMinutes ships pre-configured RLS policies, indexes, and tested security defaults alongside auth, payments, and AWS deployment. $49 one-time. Get instant access →

Common Patterns for SaaS

Public + Private Data

Some data (like published blog posts) should be public while drafts are private:

CREATE POLICY "Anyone can read published posts"
  ON posts FOR SELECT
  USING (published = true);

CREATE POLICY "Authors can read own drafts"
  ON posts FOR SELECT
  USING (author_id = auth.uid() AND published = false);

Subscription-Gated Features

Combine RLS with subscription status for feature gating:

CREATE POLICY "Pro users can access analytics"
  ON analytics FOR SELECT
  USING (
    organization_id IN (
      SELECT organization_id FROM subscriptions
      WHERE status = 'active'
      AND plan IN ('pro', 'enterprise')
    )
    AND organization_id IN (SELECT get_user_org_ids())
  );

Admin Override

Service role keys bypass RLS entirely, which is useful for admin dashboards and background jobs:

// Admin client bypasses RLS
import { createClient } from '@supabase/supabase-js';

const adminClient = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // Never expose to the browser
);

Testing Your RLS Policies

Never assume your policies work. Test them explicitly:

-- Impersonate a user and verify they see only their data
SET request.jwt.claims = '{"sub": "user-uuid-here"}';
SELECT * FROM projects; -- Should only return this user's projects

-- Try to access another user's data
SET request.jwt.claims = '{"sub": "different-user-uuid"}';
SELECT * FROM projects WHERE id = 'specific-project-id'; -- Should return empty

In your application tests, create two test users, create data for each, and verify that User A cannot see User B's data.

The Security Layering Approach

RLS is your strongest defense, but it's not the only one:

  1. RLS policies: Database-level enforcement (cannot be bypassed by application bugs)
  2. API validation: Input sanitization and business logic checks
  3. Middleware: Authentication verification on every request
  4. Frontend: UI-level access control (for UX, not security)

Each layer catches what the others might miss. RLS is the safety net that catches everything.

Get Started With Secure Defaults

Wiring RLS by hand on every new SaaS — user isolation, organization-based access, indexed policies, security-definer functions, the testing harness — takes most teams 8–15 hours to get right. And it's the kind of work where mistakes are expensive: a missed policy is a data breach.

SaaSInMinutes ships it done. For $49 one-time, you get RLS policies pre-configured for user isolation, proper indexes, tested policies, plus auth, payments, monitoring, and AWS deployment. You extend the policies for your specific multi-tenancy model rather than building from zero.

Security isn't a feature you add later. It's the foundation you build on.

Get instant access — $49 one-time →

Written by SaaSInMinutes