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".
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
- Os erros
- Diagnóstico
- RLS ligada sem política significa negar tudo
- service_role ignora RLS, e é por isso que “funciona no servidor”
- auth.uid() devolvendo nulo
- USING e WITH CHECK não são a mesma coisa
- A tabela relacionada também filtra
- A solução
- Como confirmar que resolveu
- Armadilhas que sobram depois disso
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:
> const { data, error } = await supabase.from('pedidos').select('*')
> console.log(data, error)
[] nullArray vazio e error: null. A consulta foi bem-sucedida — ela simplesmente não
enxergou linha nenhuma.
Na escrita, erro bem claro:
{
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:
// 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.
USINGdecide quais linhas existentes você enxerga. Vale paraSELECT,UPDATEeDELETE.WITH CHECKdecide se a linha que você está gravando é permitida. Vale paraINSERTeUPDATE.
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
-
Escrever uma política por comando.
migracao/01-politicas.sqlalter 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 authenticatedrestringe a política a quem tem token. Sem ele, a política também é avaliada para o papelanon, gastando processamento numa comparação que nunca será verdadeira. -
Repassar o token do usuário no servidor.
src/lib/supabase-servidor.tsimport { 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)), }, }); } -
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.sqlbegin; 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_vistovier 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 ouuiddo 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_checknula numa política deinserté o42501esperando para acontecer. -
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); -
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. -
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 owherena 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
Dados & Migrações
prepared statement s0 already exists: por que o pooler quebra
O erro só aparece depois que você troca a conexão direta pelo pooler em modo transaction. A causa está em como o Postgres guarda statements preparados.
Dados & Migrações
Prisma em serverless: resolvendo o timeout do connection pool
O pool do Prisma estoura quando cada função serverless abre o próprio conjunto de conexões. O diagnóstico completo e as três correções que aplicamos.
Dados & Migrações
Prisma, Drizzle ou postgres.js: medindo o que importa em serverless
A maioria dos benchmarks de ORM mede a coisa errada. O que pesa em função efêmera é a primeira query, não a milésima — e o script para medir isso.