TL;DR
Qui sotto ci sono le policy RLS di cui hanno bisogno la maggior parte delle app Supabase, ciascuna eseguita tramite l’API REST come ospite, come proprietario della riga e come altro utente autenticato. Tre risultati vale la pena conoscerli prima di copiare qualsiasi cosa: un update sulla riga di un altro restituisce 200 con una lista vuota, non un errore; una policy di insert dall’aspetto innocuo su una tabella di appartenenze ha permesso a un utente di unirsi a un’altra azienda e di leggerne i progetti; e scrivere (select auth.uid()) invece di auth.uid() ha portato una query da 156 ms a 7,4 ms su 200.000 righe.
Sono Denys, ingegnere full-stack e AI, e la maggior parte delle app che costruisco o sistemo gira su Supabase. Di snippet di Supabase RLS policy ce ne sono ovunque; cosa fanno quando li colpisce una richiesta reale si mostra di rado. Così li ho eseguiti.
Come ho testato. La maggior parte delle richieste è passata da uno stack Supabase locale (CLI 2.109.1, Postgres 17.6, PostgREST 14.14, Auth 2.192.0) con la publishable key e due utenti reali autenticati, A e B. I casi che non richiedono HTTP, come gli errori di sintassi e le misure di tempo, sono girati su un semplice Postgres 17.10 locale con i ruoli anon e authenticated di Supabase e auth.uid() ricreato. Tutto il 24 ottobre 2026, con righe inventate.
Prima di ogni policy: la tabella ha un grant?
Una policy decide quali righe vede un ruolo. Un grant decide se il ruolo raggiunge la tabella, e sui progetti Supabase attuali le nuove tabelle non ricevono più automaticamente i grant della Data API (dal 30 ottobre 2026 per ogni progetto). Sul mio stack una tabella con la RLS attiva e senza grant ha risposto così:
| Richiesta | Stato | Corpo |
|---|---|---|
Ospite GET /invoices |
401 |
permission denied for table invoices, con il suggerimento GRANT SELECT ... TO anon |
Utente A GET /invoices |
403 |
lo stesso, con il suggerimento per authenticated |
Quindi il primo passo per ogni tabella che l’app legge dal browser:
alter table public.todos enable row level security;
grant select, insert, update, delete on public.todos to authenticated;
Conceda solo i comandi che servono al browser, e solo a authenticated, a meno che gli ospiti debbano leggere la tabella.
Quale Supabase RLS policy mostra agli utenti solo le proprie righe?
Una policy per comando, ciascuna limitata agli utenti autenticati:
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 possiede due righe, B una. Cosa è tornato:
| Richiesta di | Cosa | Stato | Corpo |
|---|---|---|---|
| Ospite | leggere tutto | 401 |
permission denied (nessun grant per anon) |
| A | leggere tutto | 200 |
le 2 righe di A |
| A | inserire una riga di B | 403 |
new row violates row-level security policy |
| A | modificare la riga di B | 200 |
[] |
| A | eliminare la riga di B | 200 |
[] |
| A | cambiare in B il proprietario della propria riga | 403 |
new row violates row-level security policy |
Le due righe 200 [] sono quelle da ricordare. La RLS non rifiuta l’update o la delete di righe che non vede; le filtra, e zero righe modificate è un successo. Se la sua app considera “nessun errore” come “salvato”, dirà all’utente che la modifica è andata a buon fine quando non è successo nulla. Chieda le righe modificate (.select() in supabase-js, Prefer: return=representation via HTTP) e verifichi che ne sia tornata una.
Perché una policy di update ha bisogno di WITH CHECK?
using sceglie quali righe esistenti un utente può toccare; with check decide come possono apparire quelle righe dopo. Ho confrontato tre versioni della stessa policy di update:
| Policy di update | A cede la propria riga a B |
|---|---|
using (uid = user_id) with check (uid = user_id) |
403, rifiutato |
using (uid = user_id), senza with check |
rifiutato (Postgres riusa using come controllo) |
using (uid = user_id) with check (true) |
200, la riga ora appartiene a B |
Omettere with check è più sicuro di quanto sembri, perché Postgres ripiega sull’espressione using. with check (true), che alcuni generatori scrivono per “consentire gli update”, è ciò che permette a un utente di spostare una riga nell’account di qualcun altro. Scriva il controllo per intero.
Come rendere i profili pubblici ma modificabili solo dal proprietario?
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);
Nell’esecuzione su Postgres semplice: l’ospite ha letto entrambi i profili, l’update dell’ospite ha modificato 0 righe, e update profiles set display_name = 'x' di A senza filtro ha modificato esattamente 1 riga, quella di A. using (true) va bene per un select su dati pensati per essere pubblici. Su qualsiasi altra tabella equivale a non avere la RLS, e una policy scritta senza clausola to vale anche per gli ospiti: il mio ospite ha letto la riga “privata” attraverso una policy using (true) senza to.
Come condividere righe all’interno di un team?
La forma abituale: una tabella memberships e una policy sui dati che vi cerca i team di un utente.
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())));
Quella policy va bene. La falla era accanto. Ho dato a memberships la policy che molti tutorial suggeriscono, “gli utenti possono inserire la propria appartenenza”:
create policy "join any org" on public.memberships
for insert to authenticated with check (user_id = (select auth.uid()));
Controlla per chi è la riga, non per quale team. A, membro dell’azienda 1, ha inviato una richiesta:
| Richiesta di A | Stato | Corpo |
|---|---|---|
| leggere i progetti | 200 |
A roadmap |
inserire l’appartenenza {user_id: A, org_id: 2} |
201 |
creata |
| rileggere i progetti | 200 |
A roadmap, B payroll |
La correzione è non dare a memberships alcuna policy di insert, così il browser non può scriverci (nell’esecuzione Postgres lo stesso insert è stato poi rifiutato con 42501), e aggiungere i membri con un flusso di invito che gira sul server o in una funzione che verifica l’invito. Un utente che appartiene a due aziende è la trappola successiva; l’ho descritta in Supabase RLS multi-tenant: utente in due org, dati esposti.
E se una policy deve leggere la propria tabella?
“I membri vedono i colleghi di team” significa una policy su memberships che legge memberships. Postgres rifiuta al momento della query:
ERROR 42P17: infinite recursion detected in policy for relation "members2"
Sposti la ricerca in una funzione che gira con i diritti del suo proprietario, in uno schema che l’API non espone, con un search_path fisso:
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()));
Dopo, A vedeva entrambi i membri dell’azienda 1 e B vedeva solo se stesso. Tenga la funzione fuori da public: tutto ciò che c’è lì è richiamabile tramite l’API.
Come richiedere la MFA per righe sensibili?
Aggiunga una policy restrittiva sopra quella normale. Le policy restrittive si combinano con and, quindi devono passare entrambe:
create policy "mfa only" on public.payouts as restrictive
for select to authenticated using ((select auth.jwt() ->> 'aal') = 'aal2');
Con lo stesso utente, una sessione a aal1 ha letto 0 pagamenti e una sessione a aal2 ne ha letto 1.
Quali tabelle dovrebbero avere la RLS attiva e nessuna policy?
Le tabelle che tocca solo il suo server. La RLS attiva senza policy nega tutto ai ruoli del browser: nell’esecuzione Postgres l’ospite e A hanno letto 0 righe, e l’insert di A è stato rifiutato con 42501. Lasciare la RLS disattivata è l’opposto. Una tabella con la RLS disattivata e i grant predefiniti, letta con la publishable key e senza accesso:
| Richiesta dell’ospite | Stato | Corpo |
|---|---|---|
GET /leads |
200 |
entrambe le righe |
DELETE /leads?id=gt.0 |
200 |
entrambe le righe, eliminate |
È di questo che parla l’avviso dell’Advisor “RLS disabled in public”.
Una sola policy può coprire più comandi?
Non con un elenco. for update, delete è un errore di sintassi (42601: syntax error at or near ","), anche se compare ancora in raccolte di snippet pubblicate. for all è valido e applica un solo using e un solo with check a ogni comando, il che raramente è ciò che si vuole. Anche una policy di insert con using fallisce: only WITH CHECK expression allowed for INSERT.
Le policy rallentano le query?
Possono. Un’espressione di policy viene eseguita per ogni riga che Postgres considera. Ho misurato select * from big come utente A su 200.000 righe di 1.000 utenti, 200 delle quali di A (mediana di 7 esecuzioni, Postgres 17.10):
| Policy e indice | Tempo mediano |
|---|---|
auth.uid() = user_id, senza indice |
156,3 ms |
(select auth.uid()) = user_id, senza indice |
7,4 ms |
auth.uid() = user_id, indice su user_id |
0,21 ms |
(select auth.uid()) = user_id, indice |
0,05 ms |
Avvolgere la chiamata in select permette a Postgres di calcolarla una volta per query invece che una volta per riga; l’indice fa il resto. La guida alle prestazioni RLS di Supabase riporta lo stesso ordine di grandezza sulla sua tabella da 100.000 righe (da 179 ms a 9 ms). Molti articoli di blog citano quei numeri come propri; quelli sopra vengono dalla mia esecuzione.
Da dove devono venire i ruoli?
Da app_metadata o da una sua tabella, mai da user_metadata. Un utente autenticato può modificare il proprio user_metadata con supabase.auth.updateUser(), quindi una policy che controlla auth.jwt() -> 'user_metadata' ->> 'role' gli permette di assegnarsi il ruolo da solo. Un trigger di registrazione che copia un ruolo dai metadati di registrazione fa lo stesso; quel caso è in Supabase raw_user_meta_data: un signup diventato Owner.
Come verificare le proprie policy?
- Il controllo di sicurezza Supabase gratuito legge il suo progetto in sola lettura e segnala le policy sempre vere, le policy di scrittura per gli ospiti, le policy che si fidano dei metadati utente e le tabelle con la RLS disattivata, con l’SQL di correzione per ciascuna.
- Nessuno scanner sa quali righe appartengono a chi. Per questo si eseguono richieste come due utenti, come in questo articolo; il metodo completo per un’app con ruoli è in come testare la Supabase RLS.
- Cosa Supabase mette in sicurezza per Lei e cosa lascia a Lei è in Supabase è sicuro?.
- Gli stessi test sul proprietario e sul team, eseguiti anche su Appwrite, sono in Sicurezza Appwrite vs Supabase: stessi attacchi su entrambi.
Limiti. Uno stack locale e un portatile, dati inventati, 24 ottobre 2026. I tempi dipendono dall’hardware e dalla forma della tabella; contano più i rapporti dei millisecondi. Le policy di Storage e Realtime non sono trattate qui.
Non è sicuro che le sue policy reggano?
Guardo volentieri uno schema insieme a Lei. Nessuna vendita.
Mi contatti