Traduzi Meu Primeiro Aplicativo SaaS Multi-Tenant no Supabase. Aqui está O Que Aprendi.

Enviei três aplicativos SaaS multi-tenant no Supabase nos últimos dois anos. O primeiro foi um desastre. Não porque o Supabase me falhou -- não falhou -- mas porque fundamentalmente não entendia como Row Level Security (RLS) interage com o planejamento de consultas em escala. O segundo foi melhor. O terceiro lida com 2.000+ inquilinos com tempos de consulta abaixo de 50ms em tabelas com milhões de linhas.

Este artigo é tudo o que gostaria que alguém tivesse me dito antes daquele primeiro projeto. Vamos construir um schema multi-tenant real do zero, configurar políticas de RLS que realmente funcionam, e cobrir os casos extremos que só aparecem quando você tem tráfego real atingindo seu banco de dados.

Índice

Guia de Design de Schema Multi-Tenant RLS Supabase para Produção

Abordagens de Multi-Tenância no Supabase

Antes de escrevermos uma única linha de SQL, vamos deixar claro as três abordagens para multi-tenância e por que uma delas é vencedora para a maioria dos projetos Supabase.

Abordagem Nível de Isolamento Complexidade Custo por Inquilino Melhor Para
Banco de dados por inquilino Mais alto Muito alto $25+/mês por inquilino Enterprise, compliance pesado
Schema por inquilino Alto Alto $5-15/mês por inquilino SaaS mid-market
Schema compartilhado + RLS Médio Médio Centavos por inquilino Maioria das aplicações SaaS

Banco de dados por inquilino é o que você usaria se estivesse vendendo para bancos ou empresas de saúde que literalmente exigem infraestrutura separada. Supabase não facilita isso -- você estaria gerenciando múltiplos projetos Supabase.

Schema por inquilino (usando schemas PostgreSQL como tenant_123.projects) parece atraente mas se torna um pesadelo de manutenção. Cada migração é executada contra cada schema. Tentei isso uma vez. Com 400 inquilinos, uma simples migração ALTER TABLE levou 45 minutos.

Schema compartilhado com RLS é o ponto ideal para 90% das aplicações SaaS. Um conjunto de tabelas, um conjunto de migrações, e as políticas RLS do PostgreSQL lidam com o isolamento. É isso que estamos construindo aqui.

A Base: Schema de Inquilino e Usuário

Vamos começar com as tabelas principais. Vou mostrar o schema que uso em produção, não um brinquedo de tutorial.

-- Enable UUID generation
create extension if not exists "uuid-ossp";

-- Tenants (organizations, workspaces, whatever you call them)
create table public.tenants (
  id uuid primary key default uuid_generate_v4(),
  name text not null,
  slug text unique not null,
  plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
  settings jsonb not null default '{}',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- Membership junction: connects auth.users to tenants
create table public.tenant_memberships (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz not null default now(),
  
  unique(tenant_id, user_id)
);

-- Critical: indexes that RLS policies will lean on
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);

Duas coisas a notar. Primeiro, estou usando uma tabela de junção (tenant_memberships) em vez de colocar tenant_id diretamente no perfil do usuário. Usuários podem pertencer a múltiplos inquilinos -- esse é um requisito real para quase toda aplicação SaaS. Segundo, esses índices não são opcionais. Sem eles, cada verificação de RLS faz uma varredura sequencial na tabela de memberships. Vi isso adicionar 200ms+ a consultas uma vez que você tem alguns milhares de memberships.

Projetando Políticas de RLS Que Não Matam Performance

Aqui é onde a maioria dos tutoriais te falha. Eles mostram uma política simples como:

-- NÃO FAÇA ISSO EM PRODUÇÃO
create policy "Users can view their tenant's data"
  on public.projects for select
  using (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid()
    )
  );

Isso funciona. Vai passar em seus testes. E aí você implanta, consegue 500 usuários, e se pergunta por que seu dashboard leva 4 segundos para carregar.

O problema é que o PostgreSQL avalia essa subconsulta para cada linha. O planejador de consultas pode às vezes otimizá-la, mas frequentemente não -- especialmente com JOINs envolvidos.

O Padrão de Função Security Definer

Aqui está o que realmente funciona em produção:

-- Create a function that returns the user's tenant IDs
-- SECURITY DEFINER means it runs with the function creator's permissions
-- This is critical: it bypasses RLS on the memberships table itself
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
  select tenant_id 
  from public.tenant_memberships 
  where user_id = auth.uid();
$$;

-- Now use it in policies
create policy "tenant_isolation_select"
  on public.projects for select
  using (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_insert"
  on public.projects for insert
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_update"
  on public.projects for update
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_delete"
  on public.projects for delete
  using (tenant_id in (select public.get_user_tenant_ids()));

Por que é mais rápido? A palavra-chave STABLE diz ao PostgreSQL que a função retorna o mesmo resultado dentro de uma única statement. O planejador pode chamá-la uma vez e reutilizar o resultado. Nos meus benchmarks, isso reduz a sobrecarga de RLS em 60-80% em consultas que tocam múltiplas linhas.

A Armadilha search_path

Vê aquele set search_path = public na função? Não é opcional. Sem isso, um usuário malicioso poderia potencialmente criar uma função em outro schema que sobrescreve auth.uid() e contorna sua segurança. O time Supabase escreveu sobre isso, mas é fácil de perder.

Guia de Design de Schema Multi-Tenant RLS Supabase para Produção - arquitetura

O Padrão tenant_id: Fazendo Certo

Cada tabela que contém dados específicos do inquilino precisa de uma coluna tenant_id. Sem exceções. Mesmo que a tabela tenha uma chave estrangeira para outra tabela com escopo de inquilino. Aqui está o porquê:

-- Your projects table
create table public.projects (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- Tasks belong to projects. You might think tenant_id is redundant here.
create table public.tasks (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  project_id uuid not null references public.projects(id) on delete cascade,
  title text not null,
  status text not null default 'todo',
  created_at timestamptz not null default now()
);

-- Index for RLS + common query patterns
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);

Sim, tenant_id em tasks é desnormalizado. Sim, é a decisão correta. Sem isso, a política de RLS em tasks precisaria fazer JOIN com projects para verificar o inquilino -- e esse JOIN acontece em cada consulta. Com o tenant_id desnormalizado, a verificação de RLS é uma simples busca de índice.

Enforço consistência com um trigger:

create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
  if NEW.tenant_id != (
    select tenant_id from public.projects where id = NEW.project_id
  ) then
    raise exception 'tenant_id mismatch: task tenant does not match project tenant';
  end if;
  return NEW;
end;
$$;

create trigger check_task_tenant
  before insert or update on public.tasks
  for each row execute function public.verify_tenant_consistency();

JWT Claims vs Database Lookups

Supabase permite que você incorpore claims customizados no token JWT. Algumas pessoas usam isso para armazenar o ID do inquilino atual:

-- Reading from JWT (fast, but has caveats)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'

Versus fazer a busca no banco de dados toda vez:

-- Database lookup (slower, but always current)
select tenant_id from tenant_memberships where user_id = auth.uid()
Aspecto JWT Claims Database Lookup
Velocidade ~0.1ms ~1-5ms
Atualidade Obsoleto até refresh do token Sempre atual
Troca de multi-inquilino Requer refresh do token Imediato
Segurança na revogação Delay até expiração do JWT Imediato
Complexidade de implementação Mais alta (precisa de Edge Function) Mais baixa

Minha recomendação: use database lookups com o padrão de função security definer para a maioria das aplicações. A diferença de performance é negligenciável quando você tem índices apropriados, e você evita uma classe inteira de bugs em torno de tokens obsoletos.

Se você está servindo 10.000+ usuários concorrentes e raspar milissegundos importa, aí sim, mova o ID do inquilino ativo para o JWT. Você vai precisar de uma Edge Function Supabase (ou um hook) para definir o claim quando usuários trocarem de inquilino, e você vai precisar lidar com o fluxo de refresh do token no cliente.

Lidando com Operações Entre Inquilinos

Algumas operações legitimamente precisam cruzar limites de inquilino. Painéis de administrador, sistemas de faturamento, agregação de analytics. Aqui está como lidar com eles com segurança.

Service Role Key (Use com Moderação)

A chave service role do Supabase contorna RLS completamente. Use-a apenas em código server-side -- nunca exponha para o cliente.

// Server-side only (Next.js API route, Edge Function, etc.)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // NUNCA exponha isso
);

// Isso contorna RLS - use com extrema cautela
const { data } = await supabaseAdmin
  .from('tenants')
  .select('id, name, plan')
  .eq('plan', 'enterprise');

Se você está construindo com Next.js (fazemos muito isso em Social Animal), suas rotas de API e Server Components são o lugar certo para operações com service role.

Funções de Banco de Dados para Acesso Controlado Entre Inquilinos

Para controle mais granular, crie funções específicas:

create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
  result json;
  caller_role text;
begin
  -- Verify the caller is an admin of the target tenant
  select role into caller_role
  from tenant_memberships
  where user_id = auth.uid() and tenant_id = target_tenant_id;
  
  if caller_role not in ('owner', 'admin') then
    raise exception 'Unauthorized: requires admin role';
  end if;
  
  select json_build_object(
    'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
    'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
    'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
  ) into result;
  
  return result;
end;
$$;

Estratégia de Migração e Evolução de Schema

Adicionar tenant_id a tabelas existentes é a migração que todos temem. Aqui está a abordagem que minimiza downtime:

-- Step 1: Add nullable column
alter table public.some_existing_table 
  add column tenant_id uuid references public.tenants(id);

-- Step 2: Backfill (do this in batches for large tables)
update public.some_existing_table set tenant_id = (
  select tenant_id from public.projects 
  where projects.id = some_existing_table.project_id
)
where tenant_id is null;

-- Step 3: Add NOT NULL constraint
alter table public.some_existing_table 
  alter column tenant_id set not null;

-- Step 4: Add index
create index concurrently idx_some_table_tenant 
  on public.some_existing_table(tenant_id);

-- Step 5: Enable RLS and add policies
alter table public.some_existing_table enable row level security;

create policy "tenant_isolation" on public.some_existing_table
  for all using (tenant_id in (select public.get_user_tenant_ids()));

A palavra-chave concurrently na criação do índice é crucial -- sem isso, você vai travar a tabela durante toda a construção do índice. Em uma tabela com um milhão de linhas, isso poderia ser minutos de downtime.

Benchmarks de Performance e Otimização

Executei benchmarks em um plano Supabase Pro ($25/mês, a partir do início de 2025) com 500.000 linhas na tabela tasks espalhadas em 1.000 inquilinos.

Padrão de Consulta Sem RLS RLS Ingênua RLS Otimizada Sobrecarga
Select 50 linhas (inquilino único) 2.1ms 18.4ms 3.8ms +81%
Insert linha única 1.2ms 4.1ms 1.9ms +58%
Count com filtro 3.4ms 22.1ms 5.2ms +53%
Join em 2 tabelas 4.8ms 45.2ms 8.1ms +69%

"RLS Otimizada" significa: funções security definer, índices compostos apropriados, tenant_id desnormalizado em tabelas filhas, e volatilidade da função STABLE.

A abordagem ingênua (subconsultas inline, índices faltantes) faz RLS parecer lento. Otimizada, a sobrecarga é totalmente aceitável para workloads em produção.

Dicas Adicionais de Otimização

  1. Use índices compostos com tenant_id como coluna líder: create index on tasks(tenant_id, status, created_at)
  2. Particione tabelas grandes por tenant_id se algum inquilino único tiver milhões de linhas
  3. Use pg_stat_statements para encontrar consultas lentas -- Supabase expõe isso no painel sob Database > Query Performance
  4. Considere materialized views para analytics entre inquilinos em vez de executar agregações caras em tempo real

Endurecimento de Segurança para Produção

RLS é sua defesa primária, mas não deve ser a única.

1. Padrão Default Deny

Sempre habilite RLS sem uma política padrão -- isso significa que se você esquecer de adicionar uma política a uma nova tabela, ela fica bloqueada por padrão em vez de aberta.

alter table public.new_table enable row level security;
-- Don't add a permissive policy until you've thought through access patterns

2. Audit Logging

create table public.audit_log (
  id bigint generated always as identity primary key,
  tenant_id uuid not null,
  user_id uuid,
  action text not null,
  table_name text not null,
  record_id uuid,
  old_data jsonb,
  new_data jsonb,
  created_at timestamptz not null default now()
);

-- Generic audit trigger
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  if TG_OP = 'DELETE' then
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
    values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
    return OLD;
  else
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
    values (
      NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id, 
      to_jsonb(NEW),
      case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
    );
    return NEW;
  end if;
end;
$$;

3. Rate Limiting na Edge

RLS previne vazamento de dados, mas não previne abuso. Use Supabase Edge Functions ou seu middleware Next.js para rate limiting. Se você estiver construindo com Astro, você pode lidar com isso nos seus endpoints server.

4. Teste Suas Políticas

Escreva testes reais. Use o ambiente de desenvolvimento local do Supabase (supabase start) e teste que:

  • Usuário A não pode ver dados do inquilino do Usuário B
  • Remover uma membership revoga acesso imediatamente
  • Service role corretamente contorna RLS
  • Políticas de insert/update previnem adulteração de tenant_id
// Example test with Vitest
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';

describe('RLS Policies', () => {
  it('should prevent cross-tenant data access', async () => {
    const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      global: { headers: { Authorization: `Bearer ${tokenA}` } }
    });
    
    const { data, error } = await userA
      .from('projects')
      .select('*')
      .eq('tenant_id', TENANT_B_ID);
    
    expect(data).toHaveLength(0); // RLS should filter these out
  });
});

Exemplo de Schema Real

Aqui está um schema condensado mas completo para um SaaS de gerenciamento de projetos. Isso é próximo ao que implantei em produção.

-- Enable RLS on all tables
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;

-- Tenants: users can see tenants they belong to
create policy "view_own_tenants" on public.tenants for select
  using (id in (select public.get_user_tenant_ids()));

-- Only owners can update tenant settings
create policy "owners_update_tenants" on public.tenants for update
  using (
    id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role = 'owner'
    )
  );

-- Memberships: members can see other members in their tenants
create policy "view_tenant_members" on public.tenant_memberships for select
  using (tenant_id in (select public.get_user_tenant_ids()));

-- Only admins+ can manage memberships
create policy "admins_manage_members" on public.tenant_memberships for insert
  with check (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role in ('owner', 'admin')
    )
  );

-- Projects & Tasks: standard tenant isolation
create policy "tenant_projects" on public.projects for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_tasks" on public.tasks for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

Se você está construindo algo assim e quer um time que já fez isso antes, confira nosso trabalho de desenvolvimento de CMS headless -- emparelhamos backends Supabase com frontends headless para vários clientes.

FAQ

RLS Supabase adiciona latência significativa às consultas?

Com políticas otimizadas (funções security definer, índices apropriados, tenant_id desnormalizado), a sobrecarga é tipicamente 50-80% em cima do tempo de consulta bruto. Para uma consulta que leva 3ms sem RLS, espere 5-6ms com ela. Isso é perfeitamente aceitável para uso em produção. A abordagem ingênua pode adicionar 10-20x de sobrecarga, é por isso que otimização importa tanto.

Posso usar RLS com inscrições Supabase Realtime?

Sim. Supabase Realtime respeita políticas de RLS. Quando um cliente se inscreve em mudanças em uma tabela, receberá apenas eventos para linhas que estão autorizados a ver. Isso funciona automaticamente -- você não precisa adicionar nenhuma lógica de filtragem extra no cliente. Apenas certifique-se de que suas políticas de RLS são eficientes, porque são avaliadas para cada broadcast.

Como lidar com troca de inquilino na UI?

Armazene o ID do inquilino ativo no estado da sua aplicação (React context, Zustand store, etc.) e passe-o como um filtro em suas consultas. Como RLS já limita resultados aos inquilinos aos quais o usuário pertence, trocar é apenas uma questão de mudar qual tenant_id você filtra. Nenhum refresh de token necessário se você estiver usando a abordagem de database lookup.

Devo usar o auth integrado do Supabase ou um provedor externo como Clerk?

Supabase Auth se integra naturalmente com RLS através de auth.uid(). Se você usar um provedor externo, vai precisar sincronizar usuários no Supabase e usar custom JWT claims ou uma tabela de mapeamento. Eu ficaria com Supabase Auth a menos que você tenha uma razão específica para não fazer -- economiza trabalho de integração significativo.

Quantos inquilinos um único projeto Supabase pode lidar?

Com multi-tenância de schema compartilhado, pessoalmente executei 2.000+ inquilinos em um plano Supabase Pro ($25/mês). O limite prático depende do volume de dados total e padrões de consulta, não da contagem de inquilinos. Uma instância Supabase Pro com 2 vCPUs e 1GB de RAM pode lidar com tabelas com dezenas de milhões de linhas se seus índices estiverem certos.

O que acontece se eu esquecer de habilitar RLS em uma nova tabela?

Se você estiver usando o cliente Supabase com a chave anon, qualquer tabela sem RLS habilitada é acessível a qualquer um com essa chave. Esse é o maior footgun no Supabase. Configure uma verificação de CI que verifique que todas as tabelas no schema public têm RLS habilitada. Você pode consultar pg_tables juntado com pg_class para automatizar essa verificação.

Posso usar RLS com Supabase Edge Functions?

Sim. Edge Functions podem criar um cliente Supabase usando a chave anon (RLS se aplica) ou a chave service role (RLS contornada). Use a chave anon com o JWT do usuário para operações voltadas para o usuário, e a chave service role para tarefas administrativas que precisam de acesso entre inquilinos.

Como faço a migração de uma aplicação single-tenant para multi-tenant?

Adicione tenant_id como uma coluna nullable, faça backfill para todos os dados existentes (todas as linhas recebem o mesmo ID de inquilino já que era single-tenant), depois adicione a constraint NOT NULL, índices e políticas de RLS. Teste extensivamente com seus dados existentes antes de habilitar RLS -- uma política errada pode bloquear todos os seus usuários. Use o ambiente de desenvolvimento local do Supabase para ensaiar a migração.