---
id: supabase-rls-patterns
domain: data
agents: [data-engineer]
when: "ao implementar segurança de dados com Row Level Security no Supabase/Postgres"
---

# Supabase RLS — segurança no banco, não na aplicação

## O problema

Com Supabase, qualquer cliente com a `anon` key fala direto com o Postgres via PostgREST. Não existe
"backend que filtra antes". Se você confia no `.eq('user_id', userId)` do cliente para isolar dados, a
segurança é uma sugestão: qualquer um troca o `userId` no DevTools e lê a tabela inteira. **A fronteira
de autorização é o banco.** RLS é o mecanismo que faz o próprio Postgres decidir, linha a linha, o que
cada `role`/usuário pode `select`/`insert`/`update`/`delete`.

Os tells de RLS feito por quem não conhece:

1. **Tabela com RLS desabilitado** exposta no schema `public` — vazamento total via API.
2. **`enable row level security` sem nenhuma policy** — ninguém lê nada (RLS nega por padrão), e o dev
   "conserta" desabilitando RLS em vez de escrever a policy.
3. **`auth.uid()` chamado direto na policy** (sem `(select …)`) — reavaliado **por linha**, derruba a
   query de <1ms para segundos numa tabela de 100k linhas.
4. **Policy sem `to authenticated`** — roda o predicado caro até para `anon`, que nunca deveria passar.
5. **Policy baseada em `user_metadata`** — claim que o **próprio usuário edita**; vira escalonamento de
   privilégio (o atacante se promove a admin).
6. **`update` sem `select` correspondente** — o update "não funciona" e ninguém entende por quê.
7. **Multi-tenant filtrando `tenant_id` só na app**, sem coluna `tenant_id` na policy nem índice — um
   tenant lê dados do outro, e quando você adiciona a policy, a query fica lenta sem índice.

## O conhecimento / os princípios

### 1. Ligue RLS e conceda os grants — RLS nega por padrão

RLS só protege se estiver habilitado, e habilitar sem policy bloqueia tudo. Toda tabela em `public`
deve ter RLS ligado:

```sql
alter table public.todos enable row level security;
```

Os `grant` definem *quais operações o role pode tentar*; a policy define *quais linhas*. Os dois são
necessários:

```sql
grant select on public.todos to anon;
grant select, insert, update, delete on public.todos to authenticated;
grant select, insert, update, delete on public.todos to service_role;
```

> Sem policy, ninguém (exceto `service_role`/`bypassrls`) lê nada. A resposta certa para "ninguém lê" é
> **escrever a policy**, nunca desabilitar RLS.

### 2. USING vs WITH CHECK — eles não são intercambiáveis

- **`using`** filtra as linhas **existentes** que a query pode ver/afetar (read-side).
- **`with check`** valida as linhas **novas/modificadas** que estão sendo gravadas (write-side).

Se você só define `using` num insert/update, não há validação do que entra. Regra por operação (direto
da doc do Supabase):

| Operação | `using` | `with check` |
|---|---|---|
| `select` | sempre | nunca |
| `insert` | nunca | sempre |
| `update` | quase sempre | sempre |
| `delete` | sempre | nunca |

Postgres **não aceita múltiplas operações num único `for`** — escreva **uma policy por operação**:

```sql
-- SELECT: usa USING
create policy "Individuals can view their own todos."
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );

-- INSERT: usa WITH CHECK (impede gravar linha de outro usuário)
create policy "Users can create their own todos."
on public.todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );

-- UPDATE: USING (quais linhas pode tocar) + WITH CHECK (no que pode virar)
create policy "Users can update their own todos."
on public.todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

-- DELETE: usa USING
create policy "Users can delete their own todos."
on public.todos for delete
to authenticated
using ( (select auth.uid()) = user_id );
```

> **Armadilha:** `update` precisa de uma policy de `select` correspondente, senão o update "não
> funciona". O Postgres precisa enxergar a linha (`select`) para então atualizá-la.

### 3. Sempre especifique o role com `to`

Sem `to`, a policy é avaliada para **todos os roles**, inclusive `anon` — que paga o custo de um
predicado que nunca vai passar. Com `to authenticated`, a avaliação para cedo para visitantes.

Ordem das cláusulas: `on <tabela>` → `for <operação>` → `to <roles>` → `using/with check`.

```sql
-- leitura pública explícita
create policy "Public profiles are visible to everyone."
on public.profiles for select
to anon, authenticated
using ( true );
```

Benchmark da doc (tabela 100k linhas): adicionar `to authenticated` numa query feita por `anon` cai de
**170ms para <0,1ms** — porque a policy nem roda.

### 4. auth.uid() e auth.jwt() — e o claim que você NÃO pode confiar

- `auth.uid()` → `uuid` do usuário autenticado (vem do `sub` do JWT).
- `auth.jwt()` → claims completos do token.

Dentro do JWT existem dois objetos de metadata, e a diferença é de segurança, não de estilo:

| Claim | Origem | Confiável em RLS? |
|---|---|---|
| `app_metadata` (`raw_app_meta_data`) | gravado pelo backend/admin | **SIM** |
| `user_metadata` (`raw_user_meta_data`) | **editável pelo próprio usuário** | **NÃO — nunca** |

Usar `user_metadata` para autorização (ex.: `role`, `is_admin`, `tenant_id`) é uma vulnerabilidade: o
usuário muda o próprio claim e escala privilégio. Use sempre `app_metadata`:

```sql
-- pertencimento a time via app_metadata (seguro)
create policy "User is in team"
on public.my_table for select
to authenticated
using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams') );
```

Enforçar MFA (assurance level) com policy **restritiva** (restritiva = AND com as permissivas):

```sql
create policy "Restrict updates to MFA users."
on public.profiles
as restrictive
for update
to authenticated
using ( (select auth.jwt()->>'aal') = 'aal2' );
```

Prefira `permissive` (default, combinam por OR) na maioria dos casos; use `restrictive` só quando
precisa de uma condição que se soma a **todas** as outras (como o gate de MFA acima).

### 5. Multi-tenant: `tenant_id` na policy + índice obrigatório

Isolamento de tenant tem que viver na policy, com o `tenant_id` derivado do JWT (via `app_metadata`),
não passado pelo cliente:

```sql
-- tenant_id vem do app_metadata; cliente não consegue forjar
create policy "Tenant isolation - select"
on public.invoices for select
to authenticated
using ( tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid );

-- ÍNDICE é parte da policy, não opcional
create index idx_invoices_tenant_id on public.invoices using btree (tenant_id);
```

Toda coluna comparada na policy (`user_id`, `tenant_id`) precisa de índice. Benchmark da doc: índice em
`user_id` numa tabela de 100k linhas leva a query de **171ms para <0,1ms** (>100x).

### 6. A armadilha de performance nº 1: envolva funções em `(select …)`

Chamar `auth.uid()`, `auth.jwt()` ou qualquer função direto na policy faz o Postgres reavaliá-la **por
linha**. Envolver em `(select …)` dispara um **initPlan** que **cacheia o resultado uma vez por
statement** (válido porque o resultado não muda entre linhas):

```sql
-- LENTO: auth.uid() reavaliado por linha
using ( auth.uid() = user_id )

-- RÁPIDO: (select …) cacheia via initPlan
using ( (select auth.uid()) = user_id )
```

Vale para funções suas também:

```sql
-- de:
using ( is_admin() or auth.uid() = user_id )
-- para:
using ( (select is_admin()) or (select auth.uid()) = user_id )
```

Números reais da doc (tabela 100k linhas, antes → depois):

| Cenário | Antes | Depois |
|---|---|---|
| `auth.uid()=user_id` → `(select auth.uid())` | 179ms | 9ms |
| `is_admin()` com join → `(select is_admin())` | 11.000ms | 7ms |
| `has_role()=role` → `(select has_role())` | 178.000ms | 12ms |
| `team_id = any(...)` → versão envolvida | 173.000ms | 16ms |

### 7. SECURITY DEFINER para quebrar recursão e otimizar join

Quando a policy precisa consultar **outra tabela** que **também tem RLS** (ex.: tabela de papéis/times),
você cai em recursão ou em joins caros. A solução é uma função `security definer` — ela roda com os
privilégios de quem **definiu** (dono), **ignorando RLS** dentro dela, então a policy a chama sem
recursão:

```sql
create or replace function public.user_teams()
  returns int[] as $$
begin
  return array( select team_id from team_user where auth.uid() = user_id );
end;
$$ language plpgsql security definer;
```

Na policy, envolva o retorno em `array(select …)` para o cache do initPlan:

```sql
-- 1M linhas: =any(user_teams()) sem cache → timeout (>120s)
-- com array(select …) → 170ms; com índice → 2ms
using ( team_id = any( array(select public.user_teams()) ) );
```

> `security definer` = roda como o dono (ignora RLS interno). `security invoker` = roda como quem chama
> (respeita RLS). Funções de autorização usadas em policies são `security definer` **de propósito** —
> mas blinde-as: `search_path` fixo e lógica mínima, porque elas furam o RLS por design.

Para **views** (Postgres 15+), o default seguro é `security_invoker` para a view respeitar a RLS das
tabelas-base:

```sql
create view public.my_view
with (security_invoker = true)
as select ... ;
```

### 8. Inverta o join em vez de fazer a tabela-fonte cruzar a alvo

Joins na policy que partem da tabela protegida para a tabela de associação são caros. Inverta: filtre na
tabela de associação e compare a coluna local.

```sql
-- LENTO (9.000ms): join source → target
using ( auth.uid() in (select user_id from team_user where team_user.team_id = invoices.team_id) )

-- RÁPIDO (20ms): inverte — filtra na associação, compara coluna local
using ( team_id in (select team_id from team_user where user_id = (select auth.uid())) )
```

### 9. Nunca confie no filtro do cliente — mas adicione-o por performance

O `.eq('user_id', userId)` no cliente **não é segurança** (RLS é). Mas adicioná-lo ajuda o planner:
combinar o filtro explícito com a RLS levou a query de **171ms para 9ms** na doc, porque o índice é
usado melhor. Regra: RLS garante a segurança; o `.eq()` é otimização redundante, não substituto.

### 10. Bypass consciente: `service_role` e `bypassrls`

A `service_role` key e roles com `bypassrls` **ignoram toda RLS**. Use no servidor (jobs, webhooks,
migrações), **jamais no browser**. Expor a `service_role` no front é equivalente a desligar a RLS.

```sql
alter role "background_worker" with bypassrls;
```

## Checklist

Antes de marcar uma tabela como segura — qualquer "não" é um vazamento ou um gargalo:

- [ ] `enable row level security` está ligado em **toda** tabela do schema `public`?
- [ ] Existe uma policy por operação (`select`/`insert`/`update`/`delete`) onde aquela operação é
      permitida — e não só `select`?
- [ ] `insert`/`update` usam `with check` (não só `using`)?
- [ ] `update` tem uma policy de `select` correspondente?
- [ ] Toda policy especifica `to authenticated` (ou o role correto), nunca implícito?
- [ ] Autorização usa `app_metadata` / `auth.uid()` — **nunca** `user_metadata`?
- [ ] Toda chamada de `auth.uid()`/`auth.jwt()`/função está envolvida em `(select …)`?
- [ ] Toda coluna comparada na policy (`user_id`, `tenant_id`) tem **índice** btree?
- [ ] Multi-tenant: `tenant_id` vem do JWT (`app_metadata`), não do payload do cliente?
- [ ] Consultas a outras tabelas com RLS usam `security definer` (com `search_path` fixo) para evitar
      recursão?
- [ ] Views usam `with (security_invoker = true)` (PG 15+)?
- [ ] A `service_role` key existe **apenas** no servidor, nunca no bundle do cliente?

## Tabela de decisão "use X quando Y"

| Use… | Quando… |
|---|---|
| `using` | A condição decide quais **linhas existentes** ler/afetar (`select`, `delete`, lado-read do `update`) |
| `with check` | A condição valida **linhas novas/modificadas** sendo gravadas (`insert`, lado-write do `update`) |
| `(select auth.uid())` | **Sempre** — em qualquer policy que chame função; cacheia via initPlan (per-statement, não per-row) |
| `auth.uid() = user_id` | Isolamento simples por dono da linha (uma tabela, sem times/tenants) |
| `app_metadata` no `auth.jwt()` | Autorização por role/tenant/time — claim controlado pelo backend, não forjável |
| `user_metadata` | **Nunca** em autorização (usuário edita); só para preferências de UI sem efeito de segurança |
| Policy `permissive` (default) | Caso geral — múltiplas policies combinam por **OR** (acesso se qualquer uma passar) |
| Policy `restrictive` | Gate que deve valer junto com **todas** as outras por **AND** (ex.: exigir `aal2`/MFA) |
| `to authenticated` | Sempre que a regra só vale para logados — evita rodar predicado caro para `anon` |
| Índice btree na coluna | **Sempre** que a coluna aparece na policy (`user_id`, `tenant_id`) — ganho >100x |
| `security definer` | A policy consulta **outra tabela com RLS** (papéis/times/tenants) e há risco de recursão/join caro |
| `security invoker` (default em funções) | A função deve **respeitar** a RLS de quem a chama — e em **views** (`security_invoker = true`) |
| `array(select user_teams())` | Função retorna conjunto usado com `= any(...)` — força cache e evita timeout em tabelas grandes |
| Inverter o join (filtrar na tabela de associação) | A policy faria a tabela protegida cruzar (`join`) a tabela de associação — inverta para usar índice |
| `service_role` / `bypassrls` | Operação de servidor confiável (jobs, webhooks, migração) — **nunca** exposto ao cliente |

---

**Fonte:** Supabase Docs — [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security),
[RLS Performance and Best Practices](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv),
[AI Prompt: Create RLS policies](https://supabase.com/docs/guides/getting-started/ai-prompts/database-rls-policies).
