Em resumo
Abaixo estão as políticas RLS de que a maioria das apps Supabase precisa, cada uma testada através da API REST como convidado, como dono da linha e como outro utilizador autenticado. Há três resultados que convém conhecer antes de copiar seja o que for: um update na linha de outra pessoa devolve 200 com uma lista vazia, não um erro; uma policy de insert de aspeto inofensivo numa tabela de membros deixou um utilizador juntar-se a outra empresa e ler os seus projetos; e escrever (select auth.uid()) em vez de auth.uid() fez uma consulta passar de 156 ms para 7,4 ms com 200 000 linhas.
Sou o Denys, engenheiro full-stack e de IA, e a maioria das apps que construo ou reparo corre em Supabase. Os excertos de policies andam por todo o lado; o que fazem quando lhes chega um pedido real raramente se mostra. Por isso executei-os. Estes são os exemplos de RLS policy com que costumo começar, com o resultado de cada um.
Como testei. A maioria dos pedidos passou por uma stack local do Supabase (CLI 2.109.1, Postgres 17.6, PostgREST 14.14, Auth 2.192.0) com a chave publicável e dois utilizadores reais autenticados, A e B. Os casos que não precisam de HTTP, como erros de sintaxe e medições de tempo, correram num Postgres 17.10 local simples, com os roles anon e authenticated do Supabase e auth.uid() recriado. Tudo a 24 de outubro de 2026, com linhas inventadas.
Antes de qualquer policy: a tabela tem um grant?
Uma policy decide que linhas um role vê. Um grant decide se o role chega sequer à tabela, e nos projetos atuais do Supabase as tabelas novas já não recebem grants da Data API automaticamente (a partir de 30 de outubro de 2026 em todos os projetos). Na minha stack, uma tabela com RLS ligado e sem grant respondeu:
| Pedido | Estado | Corpo |
|---|---|---|
Convidado GET /invoices |
401 |
permission denied for table invoices, com a dica GRANT SELECT ... TO anon |
Utilizador A GET /invoices |
403 |
o mesmo, com a dica para authenticated |
Assim, o primeiro passo para cada tabela que a app lê a partir do browser é:
alter table public.todos enable row level security;
grant select, insert, update, delete on public.todos to authenticated;
Dê apenas os comandos de que o browser precisa, e só a authenticated, a não ser que os convidados tenham de a ler.
Como deixar os utilizadores ver apenas as suas próprias linhas?
Uma policy por comando, cada uma limitada a utilizadores autenticados:
create policy "read own" on public.todos
for select to authenticated using ((select auth.uid()) = user_id);
create policy "add own" on public.todos
for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "edit own" on public.todos
for update to authenticated
using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
create policy "delete own" on public.todos
for delete to authenticated using ((select auth.uid()) = user_id);
A tem duas linhas, B tem uma. O que voltou:
| Pedido de | O quê | Estado | Corpo |
|---|---|---|---|
| Convidado | ler tudo | 401 |
permission denied (sem grant para anon) |
| A | ler tudo | 200 |
as 2 linhas de A |
| A | inserir uma linha de B | 403 |
new row violates row-level security policy |
| A | update da linha de B | 200 |
[] |
| A | delete da linha de B | 200 |
[] |
| A | passar a sua linha para B | 403 |
new row violates row-level security policy |
As duas linhas 200 [] são as que há que lembrar. O RLS não rejeita um update ou delete de linhas que não consegue ver; filtra-as, e zero linhas alteradas conta como sucesso. Se a sua app trata «sem erro» como «guardado», dirá ao utilizador que a edição funcionou quando não aconteceu nada. Peça as linhas alteradas (.select() no supabase-js, Prefer: return=representation por HTTP) e confirme que voltou uma.
Porque é que uma policy de update precisa de WITH CHECK?
using escolhe que linhas existentes um utilizador pode tocar; with check decide como essas linhas podem ficar depois. Comparei três versões da mesma policy de update:
| Policy de update | A dá a sua linha a B |
|---|---|
using (uid = user_id) with check (uid = user_id) |
403, recusado |
using (uid = user_id), sem with check |
recusado (o Postgres reutiliza using como check) |
using (uid = user_id) with check (true) |
200, a linha agora é de B |
Omitir with check é mais seguro do que parece, porque o Postgres recorre à expressão de using. O que deixa um utilizador mover uma linha para a conta de outro é with check (true), que alguns geradores escrevem para «permitir updates». Escreva o check por inteiro.
Como tornar os perfis públicos mas editáveis só pelo dono?
create policy "public read" on public.profiles
for select to anon, authenticated using (true);
create policy "own edit" on public.profiles
for update to authenticated
using ((select auth.uid()) = id) with check ((select auth.uid()) = id);
No teste com Postgres simples: o convidado leu os dois perfis, o update do convidado alterou 0 linhas, e o update profiles set display_name = 'x' de A, sem filtro, alterou exatamente 1 linha, a do próprio A. using (true) serve num select sobre dados que são para ser públicos. Em qualquer outra tabela equivale a não ter RLS, e uma policy escrita sem cláusula to aplica-se também aos convidados: o meu convidado leu a linha «privada» através de uma policy using (true) sem to.
Como partilhar linhas dentro de uma equipa?
A forma habitual: uma tabela memberships e uma policy sobre os dados que procura aí as equipas do utilizador.
create policy "members read" on public.projects
for select to authenticated
using (org_id in (select org_id from public.memberships
where user_id = (select auth.uid())));
Essa policy está bem. A fuga estava ao lado. A memberships dei a policy que muitos tutoriais sugerem, «os utilizadores podem inserir a sua própria adesão»:
create policy "join any org" on public.memberships
for insert to authenticated with check (user_id = (select auth.uid()));
Verifica para quem é a linha, não a que equipa. A, membro da empresa 1, enviou um pedido:
| Pedido de A | Estado | Corpo |
|---|---|---|
| ler projetos | 200 |
A roadmap |
inserir adesão {user_id: A, org_id: 2} |
201 |
criada |
| ler projetos outra vez | 200 |
A roadmap, B payroll |
A correção é não dar a memberships nenhuma policy de insert, para que o browser não possa escrever nela (no teste com Postgres, o mesmo insert foi então recusado com 42501), e adicionar membros através de um fluxo de convite que corra no servidor ou numa função que valide o convite. Um utilizador que pertence a duas empresas é a armadilha seguinte; escrevi sobre isso em porque é que um utilizador em duas orgs faz vazar dados.
E se uma policy precisar de ler a sua própria tabela?
«Os membros podem ver os colegas de equipa» significa uma policy em memberships que lê memberships. O Postgres recusa ao executar a consulta:
ERROR 42P17: infinite recursion detected in policy for relation "members2"
Mova a pesquisa para uma função que corra como o seu dono, num schema que a API não expõe, com um search_path fixo:
create schema private;
create function private.user_org_ids() returns setof int
language sql stable security definer set search_path = ''
as $$ select org_id from public.members2 where user_id = (select auth.uid()) $$;
revoke all on function private.user_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;
create policy "see teammates" on public.members2
for select to authenticated using (org_id in (select private.user_org_ids()));
Depois disso, A viu os dois membros da empresa 1 e B viu apenas a si próprio. Mantenha a função fora de public: tudo o que lá estiver pode ser chamado através da API.
Como exigir MFA para linhas sensíveis?
Acrescente uma policy restritiva por cima da normal. As policies restritivas combinam-se com and, por isso ambas têm de passar:
create policy "mfa only" on public.payouts as restrictive
for select to authenticated using ((select auth.jwt() ->> 'aal') = 'aal2');
Com o mesmo utilizador, uma sessão em aal1 leu 0 pagamentos e uma sessão em aal2 leu 1.
Que tabelas devem ter RLS ligado e nenhuma policy?
As tabelas que só o seu servidor toca. RLS ligado sem policy nega tudo aos roles do browser: no teste com Postgres, o convidado e A leram 0 linhas, e o insert de A foi recusado com 42501. Deixar o RLS desligado é o oposto. Uma tabela com RLS desligado e grants por omissão, lida com a chave publicável e sem iniciar sessão:
| Pedido do convidado | Estado | Corpo |
|---|---|---|
GET /leads |
200 |
as duas linhas |
DELETE /leads?id=gt.0 |
200 |
as duas linhas, apagadas |
É disto que trata o aviso do Advisor «RLS disabled in public».
Uma policy pode cobrir vários comandos?
Não com uma lista. for update, delete é um erro de sintaxe (42601: syntax error at or near ","), embora continue a aparecer em coleções de excertos publicadas. for all é válido e aplica um único using e um único with check a todos os comandos, o que raramente é o que se quer. Uma policy de insert com using também falha: only WITH CHECK expression allowed for INSERT.
As policies tornam as consultas mais lentas?
Podem. Uma expressão de policy corre para cada linha que o Postgres considera. Medi select * from big como utilizador A com 200 000 linhas de 1000 utilizadores, das quais A tem 200 (mediana de 7 execuções, Postgres 17.10):
| Policy e índice | Mediana |
|---|---|
auth.uid() = user_id, sem índice |
156,3 ms |
(select auth.uid()) = user_id, sem índice |
7,4 ms |
auth.uid() = user_id, índice em user_id |
0,21 ms |
(select auth.uid()) = user_id, índice |
0,05 ms |
Envolver a chamada em select deixa o Postgres calculá-la uma vez por consulta em vez de uma vez por linha; o índice faz o resto. O guia de desempenho de RLS do Supabase indica uma variação da mesma ordem na sua própria tabela de 100 000 linhas (de 179 ms para 9 ms). Muitos artigos de blogue citam esses números como seus; os de cima são da minha execução.
De onde devem vir os roles?
De app_metadata ou de uma tabela sua, nunca de user_metadata. Um utilizador autenticado pode alterar o seu próprio user_metadata com supabase.auth.updateUser(), por isso uma policy que verifique auth.jwt() -> 'user_metadata' ->> 'role' deixa-o atribuir-se o role a si próprio. Um trigger de registo que copia um role dos metadados do registo faz o mesmo; esse caso está em como um registo virou Owner.
Como verificar as suas RLS policies?
- O verificador de segurança do Supabase, gratuito, lê o seu projeto só em leitura e assinala as policies sempre verdadeiras, as policies de escrita para convidados, as policies que confiam nos metadados do utilizador e as tabelas com RLS desligado, com o SQL de correção para cada uma.
- Nenhum scanner sabe que linhas pertencem a quem. Para isso, corra pedidos como dois utilizadores, como faz este artigo; o método completo para uma app com roles está em como testar o Supabase RLS.
- O que o Supabase protege por si e o que lhe deixa a si está em o Supabase é seguro?.
- Os mesmos testes de dono e de equipa, corridos também no Appwrite, estão em Segurança Appwrite vs Supabase: os mesmos ataques nos dois.
Limites. Uma stack local e um portátil, dados inventados, 24 de outubro de 2026. Os tempos dependem do hardware e da forma da tabela; as proporções importam mais do que os milissegundos. As policies de Storage e o Realtime não são tratados aqui.
Não tem a certeza de que as suas policies aguentam?
Terei gosto em ver um schema consigo. Sem pitch.
Fale comigo