Guia · Banco de dados
Isolamento por linha no Supabase
Como fazer o banco recusar o dado de outro cliente — mesmo quando a tela erra, o filtro cai ou alguém chama a API direto.
Contexto
Tabela aberta → isolamento por linha
Quase todo app em Supabase começa do mesmo jeito: a tela filtra pelo cliente logado e o banco entrega o que for pedido. Funciona até alguém trocar um id na chamada da API e receber os dados de outra empresa. O filtro estava na tela; a trava nunca existiu.
Isolamento por linha é mover essa trava para dentro do banco: cada linha carrega o dono e o Postgres recusa o que não é dele, venha o pedido da sua tela, de um script ou de um terminal. Isso é RLS (Row Level Security, segurança por linha), e é o mesmo protocolo que aplicamos em todo sistema multi-inquilino (multi-tenant, vários clientes no mesmo banco) que operamos.
Passo a passo
Como montar, na ordem.
- 01
Coloque o dono na própria linha
A trava só existe se o banco souber de quem é cada registro. Adicione a coluna: alter table public.pedidos add column if not exists user_id uuid references auth.users (id) default auth.uid(); Se a tabela já tem dados, preencha as linhas antigas antes de exigir o campo e só então rode alter table public.pedidos alter column user_id set not null; Coluna de dono que aceita nulo é a mesma coisa que não ter dono. Um aviso que vale a página inteira: o default só entra quando a coluna é omitida no insert — quem mandar user_id no corpo da requisição sobrescreve ele numa boa. Default é conveniência, não trava; quem impede gravar no nome de outro é o with check do passo 3.
- 02
Ative a RLS na tabela
Policy (regra de acesso escrita no banco) sem RLS ativada é decoração: aparece no painel, dá sensação de proteção e não bloqueia nada. Quem liga a trava é uma linha: alter table public.pedidos enable row level security; A partir dela a tabela passa a negar por padrão — sem nenhuma policy, os papéis anon e authenticated não leem nem escrevem. Faça isso na mesma migration que cria a tabela: tabela nova sem isolamento é bug de segurança, não pendência de backlog.
- 03
Escreva uma policy por operação
Ler, criar, alterar e apagar são quatro decisões diferentes; escreva as quatro. Leitura: create policy pedidos_select_proprio on public.pedidos for select to authenticated using ((select auth.uid()) = user_id); Criação: create policy pedidos_insert_proprio on public.pedidos for insert to authenticated with check ((select auth.uid()) = user_id); Alteração: create policy pedidos_update_proprio on public.pedidos for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); Exclusão: mesma coisa com for delete e só o using. O using decide o que a pessoa enxerga e o with check decide o que ela pode gravar — sem with check no insert, dá para criar linha no nome de outro. E o (select auth.uid()) entre parênteses não é estilo: faz o Postgres calcular o usuário uma vez por consulta em vez de uma vez por linha.
- 04
Indexe toda coluna citada na policy
A policy vira condição em toda consulta daquela tabela, inclusive nas que já eram lentas. Sem índice, cada leitura varre a tabela inteira e o app fica pesado exatamente quando cresce: create index if not exists pedidos_user_id_idx on public.pedidos (user_id); Vale para qualquer coluna que apareça em using ou with check — user_id, empresa_id, o que for. Segurança que derruba a performance acaba sendo desligada por alguém depois.
- 05
Multi-inquilino: resolva a empresa dentro do banco
Quando o sistema atende várias empresas, o dono da linha não é a pessoa: é a empresa. Guarde empresa_id na linha e resolva o vínculo no servidor, nunca a partir do que o cliente mandou. E crie a função fora do public: tudo que mora em public vira endpoint em /rest/v1/rpc automaticamente, e função com security definer roda com o poder de quem a criou — exposta, ela é uma porta a mais. Rode create schema if not exists privado; e depois create or replace function privado.empresa_do_usuario() returns uuid language sql stable security definer set search_path = '' as $$ select empresa_id from public.perfis where id = (select auth.uid()) $$; e então feche o acesso de fora sem quebrar o de dentro: revoke execute on function privado.empresa_do_usuario() from public, anon; grant usage on schema privado to authenticated; grant execute on function privado.empresa_do_usuario() to authenticated; A policy passa a ser using (empresa_id = (select privado.empresa_do_usuario())). Esse grant para authenticated não é descuido, é obrigatório: a policy é avaliada com o papel de quem está consultando, então revogar o execute dele derruba a leitura inteira com permission denied for function. Quem tira a função da internet é o schema privado, que a API REST não publica — não o revoke. O security definer deixa a função ler a tabela de vínculo mesmo com RLS ligada nela, e o search_path vazio impede que alguém troque o significado de perfis por baixo dela.
- 06
Papel elevado não pode zerar a empresa
Aqui mora a falha mais cara, e ela não é hipótese: em maio de 2026 fechamos exatamente essa brecha num sistema nosso, por migration. Policies permissivas se somam com OU — uma policy de admin sem filtro de empresa anula o isolamento de todas as outras, e o administrador de um cliente passa a enxergar os dados dos demais. Duas saídas, e a segunda sobrevive à distração: repetir empresa_id = (select privado.empresa_do_usuario()) dentro de cada policy, ou fixar o inquilino numa policy restritiva, que entra com E em todas as outras — create policy pedidos_tenant on public.pedidos as restrictive for all to authenticated using (empresa_id = (select privado.empresa_do_usuario())); Papel decide o que a pessoa faz dentro da empresa; nunca decide de qual empresa ela é.
- 07
Teste com dois usuários, pela API e não pela tela
Testar pela interface não prova nada, porque a interface filtra. Crie dois usuários em empresas diferentes, pegue o token de acesso de cada um e chame a API REST direto. Carregue os valores em variáveis antes, para chave nenhuma ficar no histórico do terminal: export PROJETO=https://SEU-PROJETO.supabase.co, export ANON=... e export TOKEN_A=... Depois rode curl "$PROJETO/rest/v1/pedidos?select=*" -H "apikey: $ANON" -H "Authorization: Bearer $TOKEN_A" e leia o que voltou. O usuário A tem que receber só o que é dele; pedir o id do B com ?id=eq.ID-DO-B tem que devolver lista vazia; e PATCH ou DELETE na linha do B tem que afetar zero linhas. Repita o teste depois de cada migration que mexe na tabela — policy quebra em silêncio, sem erro no build.
Limites
O que isso não é.
Não é validação de regra de negócio — a RLS decide quem vê e quem grava cada linha, não se o valor está certo. Preço, limite, status e cálculo continuam validados no servidor, com constraint, trigger ou rota própria.
Não é criptografia — quem tem acesso legítimo à linha lê o conteúdo inteiro dela. Dado sensível ainda exige decidir se vai ser guardado, onde e por quanto tempo.
Não é proteção contra o seu próprio backend — a chave service_role (chave de serviço) e conexões administrativas passam por cima de qualquer policy. RLS protege o banco do cliente, não de um endpoint mal escrito no servidor.
Riscos conhecidos
Erros que matam o projeto.
Criar as policies e esquecer o enable row level security — elas aparecem no painel, parecem proteção e não bloqueiam absolutamente nada.
Usar a chave service_role no frontend ou em variável NEXT_PUBLIC_ — ela ignora toda policy, e chave publicada se rotaciona, não se esconde.
Escrever policy que confia em dado vindo do cliente — os metadados do usuário no token são graváveis pelo próprio usuário, e empresa_id enviado no corpo da requisição é só texto.
Próximo passo
Se você tem um app em Supabase e não sabe dizer se o banco recusa o dado de outro cliente, esse é exatamente o tipo de coisa que a gente olha no diagnóstico. É gratuito e sai com o que corrigir primeiro.
Continuar aplicando
Este guia é a primeira camada. A Comunidade é o acompanhamento.
Uma entrega prática por semana, o grupo fechado com o Gabriel presente e o acervo do que já foi publicado. Turma fundadora com 40 vagas, R$ 297 por ano — o preço trava enquanto você for assinante.
Ver a Comunidade