Pular para o conteúdo
IA4Biz

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 é.

  7. 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.

Falar no WhatsApp← Ver todos os guias

Guia aberto · sem cadastro · sem e-mail obrigatório

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