Supabase in React: il client si configura in dieci minuti, la sicurezza no
I tutorial su Supabase e React si somigliano tutti: installa il pacchetto, crea il client, chiama signInWithPassword, mostra la lista. Funziona, e in dieci minuti hai qualcosa che gira.
Il problema è che a quel punto hai un database aperto su internet con una chiave che chiunque può leggere dal bundle JavaScript. La parte che decide se la tua app è sicura o no arriva dopo, e nei tutorial di solito non c’è.
Il setup, velocemente
npm install @supabase/supabase-js
// src/lib/supabase.js
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
)
Una cosa sola conta in questo blocco: quale chiave ci metti. In un’app React tutto ciò che finisce in import.meta.env.VITE_* viene inglobato nel bundle e servito al browser. Va usata la chiave pubblicabile, mai la service_role, che bypassa ogni controllo di accesso. Non è una chiave “di backend da tenere riservata se possibile”: è una chiave che dà accesso completo al database, e nel frontend non ci va in nessuna circostanza.
Che la chiave pubblicabile sia leggibile da chiunque, invece, è previsto dal progetto. È sicura solo perché il database ha delle policy. Se non le scrivi, non lo è.
La sessione
Il pattern è una sottoscrizione agli eventi di autenticazione, con la lettura iniziale per il caso in cui l’utente sia già loggato da una visita precedente:
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabase'
export function useSession() {
const [session, setSession] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session)
setLoading(false)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => setSession(session)
)
return () => subscription.unsubscribe()
}, [])
return { session, loading }
}
Due dettagli che fanno la differenza tra un hook che funziona e uno che perde pezzi.
Il return () => subscription.unsubscribe(). Senza, ogni mount lascia in piedi un listener. In sviluppo con StrictMode ne vedi subito due, in produzione li accumuli navigando.
Lo stato loading. getSession() è asincrona: al primo render la sessione è null anche per un utente autenticato. Se il routing decide in base a session === null senza distinguere “non loggato” da “non lo so ancora”, l’utente viene sbattuto sulla pagina di login a ogni refresh, per una frazione di secondo o stabilmente.
Una precisazione su cosa puoi fidarti: getSession() legge il token dallo storage locale, non lo verifica. Per le decisioni che contano c’è getClaims(), che valida la firma del JWT (localmente contro il JWKS se il progetto usa chiavi asimmetriche, altrimenti con una chiamata al server). Nel frontend la distinzione è meno critica di quanto sembri, perché la sicurezza vera non è qui: è nel database.
La parte che manca: RLS
Ogni tabella raggiungibile dalla Data API deve avere Row Level Security attiva. Senza, la chiave pubblicabile del tuo bundle legge tutto.
alter table notes enable row level security;
create policy "ognuno legge le proprie note"
on notes for select
to authenticated
using ( (select auth.uid()) = user_id );
Due elementi, e servono entrambi. to authenticated limita la policy agli utenti autenticati; using limita quali righe vedono. Fermarsi al primo è l’errore più comune e più costoso: significa che qualsiasi utente registrato legge i dati di tutti gli altri. L’autenticazione senza autorizzazione non è sicurezza, è una porta con la maniglia.
Per la scrittura servono due clausole:
create policy "ognuno modifica le proprie note"
on notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
using decide quali righe puoi toccare, with check cosa possono diventare dopo la modifica. Se ometti with check, un utente può cambiare il user_id di una propria riga e regalarla, o peggio, riassegnarsela da un’altra parte.
Tre trappole che non danno errore
Il tratto comune di queste tre è che nessuna produce un messaggio di errore. Falliscono in silenzio, ed è per questo che si scoprono tardi.
user_metadata è scrivibile dall’utente. Finisce nel JWT e sembra comodissimo per metterci un ruolo. Un utente può modificarlo dal client, quindi una policy che ci si appoggia si aggira in trenta secondi. Per l’autorizzazione va usato app_metadata, che il client non può toccare.
Le viste ignorano RLS. Una view sopra una tabella protetta gira con i privilegi di chi l’ha creata e restituisce tutto. Da Postgres 15 si crea con WITH (security_invoker = true). È il modo più elegante di annullare il lavoro fatto sulle policy senza accorgersene.
Un update senza policy di select restituisce zero righe. In Postgres l’update deve prima leggere la riga. Se hai scritto solo la policy di update, la chiamata va a buon fine, non solleva errori, e non modifica niente. Ore di debug sul frontend per un problema che sta nel database.
In pratica
L’ordine che consiglio è il contrario di quello dei tutorial: prima la tabella con RLS attiva e le policy scritte, poi il client React. Verifica le policy interrogando il database come utente normale, non dalla dashboard, che gira con privilegi elevati e ti mostra sempre tutto.
Il resto dell’integrazione, i dati, il realtime, lo storage, è la parte facile. Quella si impara dalla documentazione in un pomeriggio. Le policy invece sono l’unica cosa che, se sbagli, non te lo dice nessuno.