Pular para o conteúdo
upgbp

RLS no Supabase: por que sua query retorna array vazio

Com Row Level Security ligada, o SELECT bloqueado não dá erro — devolve nada. Como depurar a política sem desligar a segurança para "testar".

11 min de leitura

O erro tratado aqui

new row violates row-level security policy for table "pedidos" (42501)

Ambiente testado

  • Supabase Postgres 15
  • supabase-js 2
  • Prisma 6.5
Neste artigo (11)

Contexto: o que estava rodando

Um aplicativo em que cada usuário vê apenas os próprios pedidos. Até então a regra vivia no código: toda consulta acrescentava where usuarioId = sessao.id.

Funciona até alguém esquecer o where em uma rota. Mover a regra para o banco — com Row Level Security — significa que esquecer deixa de ser possível.

Liguei a RLS. Tudo parou de funcionar, de duas maneiras diferentes.

Os erros

Na leitura, nenhum erro:

console do navegador
> const { data, error } = await supabase.from('pedidos').select('*')
> console.log(data, error)
[]  null

Array vazio e error: null. A consulta foi bem-sucedida — ela simplesmente não enxergou linha nenhuma.

Na escrita, erro bem claro:

console do navegadorexit 1
{
  code: '42501',
  message: 'new row violates row-level security policy for table "pedidos"',
  details: null,
  hint: null
}

Diagnóstico

RLS ligada sem política significa negar tudo

Esta é a regra que explica o array vazio. alter table ... enable row level security não abre nada — ele fecha tudo e passa a exigir que cada acesso seja autorizado por uma política.

E o comportamento do Postgres é diferente por comando:

Comando Sem política que autorize
SELECT Devolve zero linhas, sem erro
INSERT Erro 42501
UPDATE Zero linhas afetadas, sem erro
DELETE Zero linhas afetadas, sem erro

Ler é filtrado em silêncio porque, do ponto de vista do banco, a linha simplesmente não existe para você. Isso é coerente — e é exatamente o que torna o problema difícil de perceber.

service_role ignora RLS, e é por isso que “funciona no servidor”

O sintoma clássico: funciona no código de servidor e falha no navegador. A causa é a chave usada.

Chave Papel no Postgres RLS
anon anon Aplicada
anon + JWT do usuário authenticated Aplicada, com auth.uid() preenchido
service_role service_role Ignorada

auth.uid() devolvendo nulo

A política mais comum compara a coluna com o usuário do token:

create policy "dono lê" on pedidos
  for select using (auth.uid() = usuario_id);

auth.uid() lê o sub do JWT que veio na requisição. Se não houver token, ele devolve NULL, e NULL = qualquer_coisa é NULL — que não é verdadeiro. A política nega, e você recebe array vazio.

Isso acontece quando o servidor cria o cliente com a chave anônima mas não repassa o token do usuário:

src/lib/supabase-servidor.ts
// Errado: cliente sem token. auth.uid() será nulo em toda consulta.
const supabase = createClient(URL, ANON_KEY);

USING e WITH CHECK não são a mesma coisa

Este par explica o erro de INSERT acontecer mesmo com política criada.

  • USING decide quais linhas existentes você enxerga. Vale para SELECT, UPDATE e DELETE.
  • WITH CHECK decide se a linha que você está gravando é permitida. Vale para INSERT e UPDATE.

Uma política só com USING não autoriza INSERT nenhum — não existe linha prévia para avaliar. FOR ALL USING (...) sem WITH CHECK deixa a escrita bloqueada, e é a origem mais frequente do 42501.

A tabela relacionada também filtra

Se a consulta junta pedidos com enderecos e enderecos tem RLS ligada sem política, o join não encontra par e as linhas somem — mesmo com a política de pedidos correta. O array volta vazio e a política que você está olhando é a certa.

A solução

  1. Escrever uma política por comando.

    migracao/01-politicas.sql
    alter table pedidos enable row level security;
    
    -- Leitura: só as próprias linhas.
    create policy "pedidos_select_dono"
      on pedidos for select
      to authenticated
      using ((select auth.uid()) = usuario_id);
    
    -- Criação: impede gravar pedido no nome de outra pessoa.
    create policy "pedidos_insert_dono"
      on pedidos for insert
      to authenticated
      with check ((select auth.uid()) = usuario_id);
    
    -- Atualização precisa das duas cláusulas: USING escolhe o que pode ser
    -- alterado, WITH CHECK impede transferir a linha para outro dono.
    create policy "pedidos_update_dono"
      on pedidos for update
      to authenticated
      using ((select auth.uid()) = usuario_id)
      with check ((select auth.uid()) = usuario_id);

    O to authenticated restringe a política a quem tem token. Sem ele, a política também é avaliada para o papel anon, gastando processamento numa comparação que nunca será verdadeira.

  2. Repassar o token do usuário no servidor.

    src/lib/supabase-servidor.ts
    import { createServerClient } from '@supabase/ssr';
    import { cookies } from 'next/headers';
    
    export async function supabaseDoUsuario() {
      const jar = await cookies();
      // Chave anônima com os cookies de sessão: o Postgres recebe o papel
      // authenticated e auth.uid() passa a valer.
      return createServerClient(URL, ANON_KEY, {
        cookies: {
          getAll: () => jar.getAll(),
          setAll: (lista) => lista.forEach(({ name, value, options }) =>
            jar.set(name, value, options)),
        },
      });
    }
  3. Depurar assumindo o papel do usuário, sem desligar nada.

    Este é o passo que substitui tentativa e erro. Dentro de uma transação, dá para simular exatamente a requisição do usuário:

    diagnostico/simular-usuario.sql
    begin;
    
    set local role authenticated;
    set local request.jwt.claims = '{"sub":"3f2a91c4-7b8e-4d21-9a55-1c0de8f42b77"}';
    
    -- Deve devolver o mesmo que o aplicativo devolve.
    select count(*) from pedidos;
    
    -- Mostra o que auth.uid() está enxergando de fato.
    select auth.uid() as uid_visto;
    
    rollback;

    Se uid_visto vier nulo, o problema é o token, não a política. Se vier preenchido e a contagem for zero, o problema é a comparação — normalmente tipo incompatível entre a coluna e o uuid do token.

    Para ver todas as políticas ativas de uma tabela:

    select policyname, cmd, roles, qual, with_check
    from pg_policies
    where tablename = 'pedidos';

    Coluna with_check nula numa política de insert é o 42501 esperando para acontecer.

  4. Indexar a coluna da política.

    A condição da política entra em toda consulta da tabela, como um filtro adicional. Sem índice, isso é varredura completa a cada acesso:

    create index concurrently pedidos_usuario_id_idx on pedidos (usuario_id);
  5. Envolver auth.uid() num subselect.

    Escrito diretamente, auth.uid() é avaliado por linha. Dentro de (select ...), o Postgres avalia uma vez e reaproveita. Em tabelas grandes a diferença é de ordens de grandeza — é por isso que todos os exemplos acima usam essa forma.

  6. Saber onde o Prisma se encaixa.

    O Prisma conecta com o papel postgres, que é dono das tabelas e ignora RLS. Isso não é defeito: código de servidor confiável costuma precisar enxergar tudo. Mas significa que a RLS não protege nada nesse caminho — a proteção continua sendo o where na consulta.

    O desenho que funciona bem é separar os papéis: RLS protegendo o acesso direto do navegador via supabase-js, e o Prisma reservado para rotinas de servidor que precisam de visão completa.

Como confirmar que resolveu

Com a chave anônima e um usuário logado, a leitura devolve apenas as próprias linhas:

const supabase = await supabaseDoUsuario();
const { data } = await supabase.from('pedidos').select('id, total_cents');
console.log(data?.length); // > 0, e só do usuário

Tente enxergar o pedido de outra pessoa. É o teste que prova a segurança, e não só o funcionamento:

const { data } = await supabase.from('pedidos').select('*').eq('id', ID_DE_OUTRO);
console.log(data); // [] — correto

Tente gravar no nome de outro usuário:

const { error } = await supabase.from('pedidos').insert({
  usuario_id: OUTRO_USUARIO,
  total_cents: 1000,
});
console.log(error?.code); // '42501' — correto

Nenhuma tabela exposta sem política:

select c.relname as tabela, c.relrowsecurity as rls_ligada,
       count(p.policyname) as politicas
from pg_class c
left join pg_policies p on p.tablename = c.relname
where c.relkind = 'r' and c.relnamespace = 'public'::regnamespace
group by c.relname, c.relrowsecurity
order by c.relrowsecurity, politicas;

Linhas com rls_ligada = false são tabelas abertas ao público pela API. Linhas com RLS ligada e zero políticas são tabelas fechadas para todo mundo.

Armadilhas que sobram depois disso

Views não herdam a RLS das tabelas de base por padrão. Uma view criada pelo dono das tabelas pode expor tudo. Crie com security_invoker = true para que ela seja avaliada com as permissões de quem consulta.

Funções security definer também contornam. Uma função declarada assim roda com os privilégios de quem a criou, ignorando as políticas. É útil de propósito — e é uma porta aberta se usada sem cuidado.

service_role no navegador é vazamento total. Ela ignora RLS por definição. Qualquer variável de ambiente com prefixo público carregando essa chave entrega o banco inteiro.

Storage tem políticas próprias. Os buckets do Supabase usam RLS sobre a tabela storage.objects, com sintaxe própria para o caminho do arquivo. Proteger a tabela não protege os arquivos.

Política errada não avisa. Uma condição que nega demais parece “sem dados”, e uma que permite demais parece “funcionando”. Só o teste de acesso cruzado — tentar ver o que não é seu — distingue os dois casos.

Continue por aqui