Supabase RLS in pratica: le policy che un agente dimentica sempre

RLS attiva non vuol dire RLS giusta. Le tre policy Supabase che un agente scrive male, i buchi intorno (service_role, view, storage) e due query SQL per verificare cosa fanno davvero, dalla documentazione ufficiale di Supabase e PostgreSQL.

Francesco Pio Nocerino10 minSviluppo
PercorsoVibe codingParte 21 di 24
Supabase RLS in pratica: le policy che un agente dimentica sempre
Indice

Questo pezzo è per chi ha fatto generare a un agente un'app con Supabase e vede "RLS enabled" su ogni tabella. Non è un racconto di incidenti personali: è una procedura costruita sulla documentazione ufficiale di Supabase e di PostgreSQL, letta il 23 ottobre 2026.

La checklist per portare in produzione un sito vibe-coded mette RLS tra i controlli bloccanti. Qui si scende di un livello: quali policy un agente tende a scrivere male, perché sembrano funzionare, e due query per vedere cosa fanno davvero.

In breve:

  • RLS attiva senza policy blocca tutto. RLS attiva con la policy sbagliata può aprire tutto. Il flag da solo non dice niente.
  • Le tre policy da rileggere sempre: quella con using (true) o senza to, quella che scrive senza un with check vero, quella che si fida di user_metadata.
  • Una UPDATE ha bisogno anche di una policy SELECT: senza, l'aggiornamento non tocca righe e non dà errore.
  • La chiave service_role (o la nuova secret key) bypassa RLS: sta solo sul server.
  • Le view create con il ruolo postgres ignorano RLS, a meno di security_invoker = true.
  • Due query bastano per una prima verifica: una sul catalogo (pg_class + pg_policies), una che simula un utente con set local role.

RLS attiva non vuol dire RLS giusta

La guida Row Level Security di Supabase lo dice in apertura: una tabella in uno schema esposto senza RLS è leggibile e scrivibile da qualsiasi ruolo che abbia un grant su di essa. Quindi la prima regola è ovvia: RLS su ogni tabella dello schema public.

Il problema è che "abilitata" è solo l'inizio. Postgres fa due controlli prima che un client tocchi una tabella. I grant decidono se un ruolo può eseguire un'operazione. Le policy decidono su quali righe. Secondo la stessa guida, sui progetti esistenti una nuova tabella in public parte con tutti i privilegi già concessi ad anon, authenticated e service_role, e aggiungere policy non li toglie.

Poi c'è il comportamento di default. La documentazione PostgreSQL sulla row security spiega che, se RLS è attiva e non esiste nessuna policy, vale un default-deny: nessuna riga visibile, nessuna modificabile. È qui che un agente sbaglia più spesso. L'app restituisce liste vuote, il modello cerca di "far funzionare" la feature, e la scorciatoia più corta è una policy che fa passare tutto.

La domanda utile non è "RLS è attiva?". È: per ogni tabella e ogni operazione, chi vede quali righe, e chi può scriverle come.

Le tre policy che un agente scrive male

1. La policy che apre tutto: using (true) e niente to

La forma tipica:

create policy "Enable read access for all users"
on public.orders for select
using ( true );

Funziona subito, ed è il motivo per cui viene generata. Ma senza clausola to la policy vale per tutti: la pagina CREATE POLICY di PostgreSQL indica che il default è PUBLIC, cioè tutti i ruoli. Con using (true) ogni visitatore non autenticato legge ogni riga che i grant gli permettono di raggiungere.

Gli Advisors di Supabase hanno un controllo dedicato, 0024_permissive_rls_policy, che segnala proprio USING (true), WITH CHECK (true) e tautologie come 1=1. La stessa pagina cita tra le cause più comuni le policy segnaposto dimenticate e il copia-incolla dagli esempi.

C'è una variante più sottile. Le policy permissive si combinano in OR: basta che una passi. Se l'agente aggiunge una seconda policy larga accanto a una corretta, quella corretta non conta più. L'advisor 0006_multiple_permissive_policies segnala questa situazione.

La forma giusta nomina il ruolo e filtra per proprietario:

create policy "Users can view their own orders"
on public.orders for select
to authenticated
using ( (select auth.uid()) = user_id );

using (true) resta legittimo per dati davvero pubblici, come un catalogo o i post pubblicati. In quel caso va scritto apposta, sulla tabella giusta, con il ruolo dichiarato.

2. La policy che scrive senza un with check vero

USING e WITH CHECK fanno due lavori diversi. USING decide quali righe esistenti sono visibili o modificabili. WITH CHECK decide com'è fatta la riga nuova o quella risultante dopo l'aggiornamento. Per INSERT conta solo WITH CHECK, per UPDATE contano entrambi.

L'errore tipico su INSERT:

create policy "Users can insert"
on public.profiles for insert
to authenticated
with check ( true );

Qualsiasi utente autenticato può creare una riga con il user_id di un altro. La guida Supabase usa l'esempio opposto: with check ( (select auth.uid()) = user_id ), così nessuno può creare righe che appartengono a qualcun altro.

Su UPDATE lo schema corretto della guida combina le due clausole:

create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

Se il with check manca, PostgreSQL usa l'espressione di USING anche come controllo sulla riga nuova. Il buco si apre quando l'agente scrive with check (true) per sbloccare un errore: a quel punto l'utente può aggiornare la sua riga e riassegnarla a un altro user_id.

Il secondo tranello su UPDATE è meno ovvio. La guida Supabase avverte che per eseguire un UPDATE serve anche una policy SELECT corrispondente. La pagina CREATE POLICY spiega il perché: i comandi che richiedono permessi di lettura, come UPDATE e DELETE, vedono solo le righe ammesse dalle policy SELECT. Senza, l'aggiornamento tocca zero righe e non solleva errori. L'agente vede "nessun errore" e conclude che funziona.

3. La policy che si fida di user_metadata

Ruoli e permessi finiscono spesso nei metadati dell'utente, perché è il posto più comodo da raggiungere dal client:

create policy "Admins read everything"
on public.orders for select
to authenticated
using ( ((select auth.jwt()) -> 'user_metadata' ->> 'is_admin')::bool );

È sbagliato per costruzione. La guida RLS distingue i due campi: raw_user_meta_data può essere aggiornato dall'utente autenticato, quindi non è il posto per dati di autorizzazione; raw_app_meta_data invece l'utente non può modificarlo. L'advisor 0015_rls_references_user_metadata classifica questa situazione come errore: basta una chiamata updateUser({ data: { is_admin: true } }) dal client per passare il controllo.

Due note dalla stessa documentazione. Il JWT non è sempre aggiornato: se togli un utente da un team in app_metadata, la policy lo vede solo dopo il refresh del token. E per ruoli che cambiano spesso conviene una tabella di membership letta da una funzione security definer in uno schema non esposto, con search_path fissato.

Le tre policy in una tabella

Policy

Come la scrive l'agente

Perché sembra funzionare

Correzione

Advisor

Lettura aperta

using (true), nessun to

L'app mostra i dati

to authenticated + filtro su (select auth.uid())

0024, 0006

Scrittura

with check (true)

Insert e update passano

with check ( (select auth.uid()) = user_id ) + policy SELECT

0024

Ruoli

user_metadata nel JWT

Il flag admin si legge facile

app_metadata o tabella di membership

0015

I buchi intorno alle policy

Anche con le tre policy scritte bene, restano quattro punti che un agente tocca spesso senza dirlo.

La chiave che bypassa tutto

La guida spiega che una secret key autorizza l'accesso tramite il ruolo service_role, che ha l'attributo bypassrls. La pagina sulle API keys classifica la chiave JWT service_role come versione legacy delle secret key e indica entrambe solo per componenti backend. Nessuna delle due va nel browser. Se un agente la mette in una variabile NEXT_PUBLIC_ o VITE_ "perché così la query torna", tutte le policy diventano decorative. Dove finiscono le chiavi nei progetti generati è il tema del pezzo su segreti, .env e chiavi API nei progetti vibe-coded.

Un dettaglio utile: secondo la guida, la secret key bypassa RLS solo se la richiesta non porta il token di un utente. Se lo porta, la richiesta gira con le policy di quell'utente.

Le view

Le view create con l'utente postgres ignorano RLS per default. Su Postgres 15 e successivi la guida Supabase indica security_invoker = true, che fa valere i permessi di chi interroga la view. L'advisor 0010_security_definer_view segnala le view nello schema public senza questa opzione.

create view public.my_orders
with (security_invoker = true)
as select id, total, created_at from public.orders;

Le funzioni security definer

Una funzione security definer gira con i privilegi di chi l'ha creata, quindi su Supabase spesso con un ruolo che bypassa RLS. Se sta in uno schema esposto, è chiamabile dalla Data API. Gli advisor 0028 e 0029 segnalano le funzioni di questo tipo eseguibili da anon o authenticated. La regola della guida: mai in uno schema esposto, sempre con set search_path = ''.

Storage

I file seguono la stessa logica, sulla tabella storage.objects. La documentazione Storage Access Control indica che senza policy non sono permessi upload, che per caricare basta una policy INSERT, e che l'upsert richiede anche SELECT e UPDATE. Il pattern da chiedere all'agente è la cartella per utente:

create policy "Users upload to their own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'documents' and
  (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

Per i bucket pubblici, l'advisor 0025_public_bucket_allows_listing avverte che una policy SELECT larga rende elencabile tutto il contenuto, anche se gli URL pubblici funzionerebbero senza.

Due query per verificare cosa fanno le policy

Non servono tool esterni per una prima verifica. Le due query qui sotto usano solo il catalogo di PostgreSQL e la simulazione di ruolo descritta nella guida Supabase. Si lanciano dallo SQL Editor o con psql.

Query 1: l'inventario dal catalogo

select c.relname          as tabella,
       c.relrowsecurity   as rls_attiva,
       p.policyname,
       p.cmd,
       p.roles,
       p.permissive,
       p.qual,
       p.with_check
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policies p
  on p.schemaname = n.nspname and p.tablename = c.relname
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
order by c.relrowsecurity, c.relname, p.cmd;

relrowsecurity è il flag di RLS nel catalogo pg_class. La vista pg_policies espone per ogni policy il comando, i ruoli, se è permissiva o restrittiva, e le espressioni qual (il USING) e with_check.

Come si legge il risultato:

  • rls_attiva = false: la tabella è aperta a chiunque abbia un grant. Da sistemare per prima.
  • rls_attiva = true e policyname vuoto: default-deny. Va bene se è voluto; la pagina Advisors suggerisce di renderlo esplicito con una policy using (false).
  • roles = {public}: la policy è stata scritta senza to.
  • qual = true o with_check = true: la policy permissiva del punto 1 o del punto 2.
  • cmd = UPDATE senza una riga SELECT sulla stessa tabella: il tranello dell'update che non aggiorna niente.
  • qual o with_check che contengono user_metadata: il punto 3.
  • più righe permissive per la stessa tabella e lo stesso comando: si sommano in OR, vanno rilette insieme.

Per gli upload si ripete la stessa query cambiando lo schema in storage e la tabella in objects.

Query 2: fingersi un altro utente

La seconda query non legge le policy: le esegue. La guida Supabase cambia ruolo e identità con set local role e set local request.jwt.claim.sub, dentro una transazione che si annulla alla fine.

begin;
set local role authenticated;
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';

-- cosa vede un utente che non possiede niente
select count(*) from public.profiles;

-- cosa riesce a modificare (returning mostra le righe toccate)
update public.profiles set avatar_url = 'test.png' returning id;

-- deve fallire con 42501: riga intestata a un altro utente
insert into public.profiles (id, user_id, avatar_url)
values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', 'test.png');

rollback;

Tabella e UUID sono quelli dell'esempio profiles della guida Supabase: adattali alle tue tabelle e a un utente di test. Con policy corrette, lo sconosciuto non vede righe altrui, l'update non restituisce id e l'insert fallisce. Per controllare i visitatori non autenticati si ripete con set local role anon;. Lo stesso approccio va sulle view e su storage.objects.

La guida segnala una trappola di lettura. Un using che filtra la riga non dà errore: restituisce zero righe. Un grant mancante o una violazione di with check invece sollevano 42501. Per questo serve returning sulle scritture: "nessun errore" non vuol dire "nessuna riga toccata", e nemmeno il contrario.

Quando le due query tornano pulite, il passo successivo documentato da Supabase è trasformarle in test. Un file in supabase/tests/ per tabella, con casi allow e deny per anon e authenticated, lanciati con supabase test db.

Performance: due regole che un agente salta

Le policy girano su ogni riga candidata. La guida Supabase dà due indicazioni.

La prima è un indice su ogni colonna usata dalle policy, per esempio user_id. Attenzione alle chiavi composte: un indice btree su (team_id, user_id) non serve a filtrare su user_id, perché conta solo la prima colonna.

La seconda è avvolgere le funzioni in una select: (select auth.uid()) invece di auth.uid(). Così l'ottimizzatore la valuta una volta per statement invece che per ogni riga. L'advisor 0003_auth_rls_initplan segnala le policy che non lo fanno.

Un'ultima nota della guida su auth.uid(): senza un utente autenticato restituisce null, e null = user_id è sempre falso. La policy nega in silenzio. È corretto, ma confonde chi fa debug; dichiarare to authenticated rende l'intenzione esplicita.

Cosa chiedere all'agente prima di accettare una migration

Se l'agente scrive le policy, queste regole vanno nel prompt o nel file di istruzioni del progetto. Su come strutturare quel file c'è il pezzo su AGENTS.md, CLAUDE.md e regole di Cursor.

  • una policy per operazione (select, insert, update, delete), mai for all per comodità
  • sempre la clausola to, mai policy implicite su public
  • with check esplicito su insert e update, legato a (select auth.uid())
  • nessun riferimento a user_metadata nelle policy
  • grant e RLS nella stessa migration, con revoke esplicito per anon dove non serve
  • view con security_invoker = true, funzioni security definer fuori dagli schemi esposti
  • un indice per ogni colonna filtrata da una policy
  • nessuna chiave service_role o secret key nel codice client

Vale lo stesso principio delle dipendenze inventate dall'AI: il modello produce qualcosa che sembra giusto, la verifica resta tua. Se stai ancora scegliendo il database, il confronto SQLite vs Postgres per un prodotto indie spiega cosa cambia quando l'autorizzazione vive dentro il database. L'hub Vibe coding raccoglie gli altri pezzi del filone.

Cosa conviene fare adesso

  • Lancia la query 1 sullo schema public e su storage. Segna ogni tabella senza RLS, ogni true, ogni {public}, ogni user_metadata.
  • Lancia la query 2 con un utente che non possiede niente e con anon, su ogni tabella con dati personali.
  • Apri il Security Advisor e il Performance Advisor e confronta i risultati con le tue due liste.
  • Cerca nel repository la secret key o la service_role: deve comparire solo in codice server.
  • Trasforma i casi della query 2 in test supabase test db, così la prossima migration generata non riapre quello che hai chiuso.

RLS è uno dei pochi controlli di sicurezza che vivono dentro il database. Proprio per questo una policy sbagliata non si vede dall'app: si vede solo interrogando il catalogo e fingendosi un altro utente.

[ DOMANDE FREQUENTI ]

Domande frequenti

01Se attivo RLS senza scrivere policy, i dati sono al sicuro?

Dal lato della Data API sì. Con RLS attiva e nessuna policy vale il default-deny: nessuna riga è visibile tramite le chiavi pubbliche. Ma l'app smette di funzionare, ed è lì che un agente tende ad aggiungere policy troppo larghe. Restano esclusi dal controllo i ruoli con bypassrls, come service_role.

02Perché il mio UPDATE non dà errore ma non aggiorna niente?

Una causa documentata è la policy SELECT mancante sulla tabella. Secondo la documentazione Supabase e PostgreSQL, UPDATE vede solo le righe ammesse dalle policy di lettura: senza, trova zero righe e termina senza errore. Aggiungi returning alla query per vedere quali righe tocca davvero.

03La chiave anon (publishable) nel frontend è un problema?

No. La documentazione Supabase sulle API keys la descrive come sicura da esporre in pagine web e app. La condizione è che RLS e grant siano corretti su ogni tabella esposta. Le richieste con questa chiave girano come anon, o come authenticated se l'utente ha fatto login. Il problema è la service_role o la secret key: bypassano RLS e vanno usate solo lato server.

04Gli Advisors di Supabase sostituiscono queste verifiche?

No, le affiancano. Gli advisor sono controlli deterministici che segnalano pattern noti, come tabelle senza RLS, policy con true, view security definer e riferimenti a user_metadata. Non sanno quale accesso volevi. La simulazione con set local role e i test in supabase/tests/ verificano proprio quello.

Condividi