DEV Community

Atlas Whoff
Atlas Whoff

Posted on

PostgreSQL row-level security with Supabase: multi-tenant data isolation

Row-level security (RLS) is the difference between "our multi-tenant app probably doesn't leak data" and "it provably cannot leak data." Here's how to implement it with Supabase and why you should never ship a SaaS without it.

The problem

Every multi-tenant SaaS has the same vulnerability:

-- Without RLS, any authenticated user can do this:
SELECT * FROM invoices;
-- Returns ALL invoices for ALL tenants
Enter fullscreen mode Exit fullscreen mode

Most developers "solve" this in application code:

// Application-layer filtering
const invoices = await db.invoices.findMany({
  where: { organizationId: currentUser.orgId }
});
Enter fullscreen mode Exit fullscreen mode

This works until someone forgets the where clause. One missed filter in one endpoint and you have a data breach. RLS makes it impossible at the database level.

Enable RLS on every table

-- Step 1: Enable RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Step 2: Define policies
CREATE POLICY "Users can only see their organization's invoices"
  ON invoices
  FOR SELECT
  USING (organization_id = auth.jwt() ->> 'org_id');

CREATE POLICY "Users can only insert into their organization"
  ON invoices
  FOR INSERT
  WITH CHECK (organization_id = auth.jwt() ->> 'org_id');

CREATE POLICY "Users can only update their organization's invoices"
  ON invoices
  FOR UPDATE
  USING (organization_id = auth.jwt() ->> 'org_id');

CREATE POLICY "Users can only delete their organization's invoices"
  ON invoices
  FOR DELETE
  USING (organization_id = auth.jwt() ->> 'org_id');
Enter fullscreen mode Exit fullscreen mode

Now every query is automatically filtered. You can't accidentally return another tenant's data because the database won't let you.

Supabase makes this practical

Supabase provides auth.jwt() — a function that extracts claims from the authenticated user's JWT. This is the bridge between your auth system and your RLS policies.

-- auth.uid() returns the authenticated user's ID
-- auth.jwt() returns the full JWT payload

-- Policy: users see only their own data
CREATE POLICY "own_data" ON profiles
  FOR ALL
  USING (id = auth.uid());

-- Policy: users see their team's data
CREATE POLICY "team_data" ON projects
  FOR SELECT
  USING (
    team_id IN (
      SELECT team_id FROM team_members WHERE user_id = auth.uid()
    )
  );
Enter fullscreen mode Exit fullscreen mode

Role-based access within a tenant

-- Custom JWT claim: role
CREATE POLICY "admins_can_delete" ON invoices
  FOR DELETE
  USING (
    organization_id = auth.jwt() ->> 'org_id'
    AND auth.jwt() ->> 'role' = 'admin'
  );

CREATE POLICY "members_read_only" ON invoices
  FOR SELECT
  USING (organization_id = auth.jwt() ->> 'org_id');
Enter fullscreen mode Exit fullscreen mode

Regular members can read. Only admins can delete. The database enforces it — not your application code.

Performance: it's fast

The number one concern with RLS is performance. In practice:

-- This policy uses an index scan, not a full table scan
CREATE POLICY "org_isolation" ON invoices
  FOR ALL
  USING (organization_id = auth.jwt() ->> 'org_id');

-- Make sure you have the index
CREATE INDEX idx_invoices_org ON invoices(organization_id);
Enter fullscreen mode Exit fullscreen mode

With proper indexes, RLS adds <1ms overhead per query. PostgreSQL's query planner integrates RLS predicates directly into the query plan — they're not applied as a post-filter.

The pattern for every SaaS table

-- Template: apply to every tenant-scoped table
CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id),
  title TEXT NOT NULL,
  content TEXT,
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Read: org members can see
CREATE POLICY "documents_select" ON documents
  FOR SELECT USING (organization_id = auth.jwt() ->> 'org_id');

-- Create: org members can create (enforced org_id)
CREATE POLICY "documents_insert" ON documents
  FOR INSERT WITH CHECK (organization_id = auth.jwt() ->> 'org_id');

-- Update: only creator or admin
CREATE POLICY "documents_update" ON documents
  FOR UPDATE USING (
    organization_id = auth.jwt() ->> 'org_id'
    AND (created_by = auth.uid() OR auth.jwt() ->> 'role' = 'admin')
  );

-- Delete: admin only
CREATE POLICY "documents_delete" ON documents
  FOR DELETE USING (
    organization_id = auth.jwt() ->> 'org_id'
    AND auth.jwt() ->> 'role' = 'admin'
  );
Enter fullscreen mode Exit fullscreen mode

Testing RLS policies

-- Supabase lets you test policies by setting the JWT
SET request.jwt.claims = '{"sub": "user-123", "org_id": "org-456", "role": "member"}';

-- This should return only org-456's invoices
SELECT * FROM invoices;

-- This should fail (member, not admin)
DELETE FROM invoices WHERE id = 'some-id';
Enter fullscreen mode Exit fullscreen mode

Common mistakes

1. Forgetting to enable RLS on new tables

Every migration that creates a table should include ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Add this to your migration template.

2. Using application-level IDs instead of JWT claims

-- BAD: trusts the application to send the right org_id
USING (organization_id = current_setting('app.org_id'));

-- GOOD: uses the cryptographically signed JWT
USING (organization_id = auth.jwt() ->> 'org_id');
Enter fullscreen mode Exit fullscreen mode

3. Not handling the service role

Supabase has a service_role key that bypasses RLS. Use it only in server-side code, never expose it to the client.

// Client-side: uses anon key (RLS enforced)
const supabase = createClient(URL, ANON_KEY);

// Server-side only: bypasses RLS for admin operations
const supabaseAdmin = createClient(URL, SERVICE_ROLE_KEY);
Enter fullscreen mode Exit fullscreen mode

The bottom line

RLS is not optional for multi-tenant SaaS. It's the only reliable way to prevent data leakage. Application-level filtering is a "hope nothing goes wrong" strategy. RLS is a "provably cannot go wrong" strategy.

The setup is 10 minutes per table. The alternative is explaining to your customers why their data was visible to other tenants.

Multi-tenant data isolation is built into the AI SaaS Starter Kit — RLS policies, organization scoping, and role-based access are configured from day one.

Top comments (0)