Vos données Lovable sont-elles vraiment protégées ? Le test à faire vous-même, pas à pas
Clé publique, curl, deux comptes de test, console du navigateur, Security Advisor : le mode opératoire pour vérifier qui peut lire vos données Supabase.
Mustapha Hamadi, fondateur de Raicode14 min de lecture
Sommaire
« Lovable a activé le RLS » n’est pas une réponse à la question « mes données sont-elles protégées ? ». Une table peut avoir le RLS actif et une règle qui laisse tout passer ; un visiteur anonyme peut être bloqué alors que n’importe quel compte fraîchement créé lit les données de tous les autres. La seule réponse fiable est un test, et il se fait en une demi-heure avec un terminal, un navigateur et deux comptes de test.
Cet article décrit ce test, étape par étape. Il ne dit pas comment corriger : c’est l’objet de notre check-list de sécurité Lovable et Supabase. Ici, on se met à la place d’un visiteur, puis d’un utilisateur malveillant, et on regarde ce que la base accepte. Les commandes ont été vérifiées sur les documentations de Supabase et de PostgREST en octobre 2026.
Avant de commencer : uniquement votre application
Tout ce qui suit s’applique à une application dont vous êtes propriétaire, sur un projet Supabase ou Lovable Cloud que vous contrôlez. Lancer ces commandes contre l’application de quelqu’un d’autre, même « pour voir », même si la clé est publique, peut constituer un accès frauduleux à un système de traitement automatisé de données : l’article 323-1 du Code pénal le punit de trois ans d’emprisonnement et 100 000 € d’amende, et de cinq ans et 150 000 € en cas de modification ou de suppression de données (version en vigueur vérifiée en octobre 2026). Qu’une porte soit mal fermée n’autorise pas à l’ouvrir.
Pour les tests d’écriture, travaillez sur des lignes créées pour l’occasion, jamais sur les données de vrais clients. Préparez :
- deux comptes de test dans votre application, A et B, avec chacun quelques données (un profil, un document, une facture, selon votre produit) ;
- un terminal avec
curl, présent par défaut sur macOS et Linux (sous Windows, passez par WSL ou faites les tests de lecture depuis la console du navigateur) ; - un accès au tableau de bord : celui de Supabase si vous avez connecté votre propre projet, ou l’espace Database de Lovable Cloud.
Étape 1 : récupérer l’adresse, la clé et la liste des tables
Ouvrez votre application en production, F12, onglet Réseau (Network), filtre supabase.co, puis naviguez dans l’application. Chaque requête vers /rest/v1/ vous donne trois informations : l’adresse du projet (https://VOTRE-PROJET.supabase.co), la clé publique dans l’en-tête apikey, et le nom de la table interrogée, juste après /rest/v1/.
La clé est soit une clé anon historique (un long jeton qui commence par eyJ), soit une clé publiable sb_publishable_.... Dans les deux cas, elle est faite pour être dans le navigateur : une requête qui la porte sans session utilisateur s’exécute avec le rôle Postgres anon, celle d’un utilisateur connecté avec le rôle authenticated. Ce n’est pas une fuite. Toute la sécurité repose sur ce que ces deux rôles ont le droit de faire.
Notez les tables appelées, puis complétez la liste avec le tableau de bord (Table Editor dans Supabase, More, puis Database dans Lovable Cloud). Ne comptez pas sur l’ancienne astuce qui consistait à demander la liste des tables à l’API : depuis mars 2026, Supabase refuse l’accès au schéma OpenAPI de /rest/v1/ avec la clé anon et répond 403. Mais une table que l’interface n’utilise plus reste interrogeable par qui connaît son nom.
Pour la suite, définissez trois variables dans le terminal :
export SB_URL="https://VOTRE-PROJET.supabase.co"
export SB_KEY="VOTRE_CLE_PUBLIQUE"
export TABLE="profiles"
Étape 2 : le test du visiteur anonyme
Premier test, sans aucune session : ce que voit n’importe qui sur Internet.
curl -s "$SB_URL/rest/v1/$TABLE?select=*&limit=5" \
-H "apikey: $SB_KEY"
Avec une clé publiable, la clé va dans l’en-tête apikey et pas dans Authorization: Bearer ; la commande ci-dessus marche aussi avec une clé anon historique. Pour connaître le nombre total de lignes visibles sans les télécharger, ajoutez la préférence count=exact de PostgREST, qui renvoie le total dans l’en-tête Content-Range :
curl -s -I "$SB_URL/rest/v1/$TABLE?select=*" \
-H "apikey: $SB_KEY" \
-H "Prefer: count=exact" | grep -i content-range
Comment lire le résultat :
| Réponse | Ce que ça veut dire |
|---|---|
| des lignes en JSON | la table est lisible par n’importe qui. Grave, sauf si c’est voulu (catalogue public, articles de blog) |
[] et Content-Range: */0 | rien n’est visible en anonyme : RLS actif sans règle pour anon, ou table vide. Vérifiez qu’elle contient des lignes dans le tableau de bord |
erreur 42501 (permission denied) | le rôle anon n’a même pas le droit d’accès à la table. C’est protégé |
401 | la clé est fausse ou mal recopiée. Le test n’a rien prouvé |
Répétez pour chaque table. Une seule table ouverte suffit : c’est le schéma décrit dans l’analyse de Matt Palmer, qui avait trouvé des tables lisibles avec la seule clé publique dans environ 10 % des applications de la vitrine Lovable.
Étape 3 : le test de l’utilisateur connecté qui lit les données des autres
C’est le test le plus important, et le plus oublié. Beaucoup de règles générées disent « tout utilisateur connecté peut lire » : le visiteur anonyme est bloqué, mais il suffit de créer un compte, ce que votre page d’inscription permet à tous.
Récupérez le jeton de session du compte A par l’API d’authentification, avec le point d’entrée /token?grant_type=password :
curl -s "$SB_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"MOT_DE_PASSE_A"}'
La réponse contient un access_token (valable une heure par défaut) et l’identifiant de l’utilisateur dans user.id. Copiez le jeton :
export TOKEN_A="LE_ACCESS_TOKEN"
curl -s "$SB_URL/rest/v1/$TABLE?select=*" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $TOKEN_A"
Le compte A ne doit voir que ses lignes, ou celles de son organisation en B2B. Si une ligne appartient à B, n’importe quel inscrit lit les données de tous les autres. Pour trancher, filtrez directement sur l’identifiant de B (récupéré de la même façon avec son mot de passe) :
curl -s "$SB_URL/rest/v1/$TABLE?select=*&user_id=eq.ID_DU_COMPTE_B" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $TOKEN_A"
La réponse attendue est []. Adaptez le nom de la colonne (user_id, owner_id, organization_id) à votre schéma.
Le même test depuis la console du navigateur
Sans terminal, connectez-vous à votre application avec le compte A, ouvrez la console (F12, onglet Console) et collez ceci. Le client Supabase range la session dans le stockage local sous une clé de la forme sb-<projet>-auth-token :
const SB_URL = "https://VOTRE-PROJET.supabase.co";
const SB_KEY = "VOTRE_CLE_PUBLIQUE";
const ref = new URL(SB_URL).hostname.split(".")[0];
const session = JSON.parse(localStorage.getItem(`sb-${ref}-auth-token`));
const r = await fetch(`${SB_URL}/rest/v1/profiles?select=*`, {
headers: { apikey: SB_KEY, Authorization: `Bearer ${session.access_token}` },
});
console.log(r.status, await r.json());
Retirez la ligne Authorization pour refaire le test anonyme de l’étape 2.
Étape 4 : les écritures, verbe par verbe
Une lecture bloquée ne prouve rien sur l’écriture : chaque opération (select, insert, update, delete) a ses propres règles, et une table peut être parfaitement fermée en lecture et ouverte en modification. Testez chaque verbe, toujours avec le jeton de A contre une ligne de test appartenant à B.
Modifier la ligne d’un autre. La préférence return=representation demande à PostgREST de renvoyer les lignes réellement modifiées :
curl -s -X PATCH "$SB_URL/rest/v1/$TABLE?id=eq.ID_LIGNE_DE_B" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"display_name":"test-rls"}'
[] signifie qu’aucune ligne n’a été touchée : c’est le résultat attendu. Une ligne renvoyée signifie que A vient de modifier les données de B.
Supprimer la ligne d’un autre. Même commande avec -X DELETE et sans -d. Même lecture : [] est le bon résultat.
Créer une ligne au nom d’un autre. C’est le cas le plus souvent oublié : une règle d’insertion qui vérifie qu’on est connecté, mais pas que la ligne vous appartient.
curl -s -X POST "$SB_URL/rest/v1/$TABLE" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"user_id":"ID_DU_COMPTE_B","display_name":"test-rls"}'
Le résultat attendu est une erreur (new row violates row-level security policy).
S’attribuer des droits. Si une table contient un rôle, un plan d’abonnement, un crédit ou un indicateur is_admin, essayez de modifier votre propre ligne : PATCH sur votre id avec {"role":"admin"} ou {"plan":"pro"}. Si la base l’accepte, chaque utilisateur peut se promouvoir administrateur ou s’offrir l’abonnement payant. Une règle « chacun modifie sa propre ligne » ne protège pas les colonnes.
Supprimez ensuite les lignes de test créées par ces essais.
Étape 5 : les fonctions RPC et les vues
Dans l’onglet Réseau, les appels à /rest/v1/rpc/NOM_DE_LA_FONCTION sont des fonctions Postgres. Rejouez-les sans jeton, puis avec celui de A en visant des données de B :
curl -s -X POST "$SB_URL/rest/v1/rpc/NOM_DE_LA_FONCTION" \
-H "apikey: $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"parametre":"valeur"}'
Une fonction déclarée security definer s’exécute avec les droits de son créateur, et ignore donc le RLS. Si elle ne vérifie pas elle-même qui l’appelle, elle renverra ou modifiera ce que les tables refusent.
Les vues s’interrogent comme des tables (/rest/v1/NOM_DE_LA_VUE). Supabase prévient que les vues contournent le RLS par défaut, parce qu’elles sont généralement créées par l’utilisateur postgres. Une table bien protégée peut donc fuiter par une vue « statistiques » ou « tableau de bord ». Faites le test anonyme et le test du compte A sur chaque vue.
Étape 6 : les fichiers du stockage
Dans Storage, chaque bucket est public ou privé. Pour un bucket public, quiconque possède l’URL d’un fichier peut le télécharger. Testez avec l’adresse d’un fichier du compte B, dans une fenêtre de navigation privée :
https://VOTRE-PROJET.supabase.co/storage/v1/object/public/NOM_DU_BUCKET/chemin/du/fichier.pdf
C’est le format que produit getPublicUrl dans le client Supabase. Si le chemin contient l’identifiant de l’utilisateur ou un nom prévisible, un inconnu peut deviner celui des autres.
Vérifiez aussi qu’on ne peut pas lister le contenu d’un bucket, ce que le Security Advisor signale par l’alerte 0025_public_bucket_allows_listing :
curl -s -X POST "$SB_URL/storage/v1/object/list/NOM_DU_BUCKET" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"prefix":""}'
Le compte A ne doit voir que ses propres dossiers. Sans jeton, la liste doit être vide.
Étape 7 : lire la configuration dans le tableau de bord
Les tests de l’extérieur montrent ce qui fuit ; le tableau de bord montre pourquoi. Les requêtes qui suivent se collent dans le SQL Editor de Supabase. Sur Lovable Cloud, l’interface ne propose pas d’éditeur SQL : les règles se lisent dans More, puis Database, Manage et Policies, une vue en lecture seule.
Le RLS est-il actif sur chaque table ?
Cette requête donne, pour chaque table du schéma public, l’état du RLS et le nombre de règles :
select c.relname as table_name,
c.relrowsecurity as rls_actif,
count(p.policyname) as nb_regles
from pg_class c
left join pg_policies p
on p.schemaname = 'public' and p.tablename = c.relname
where c.relnamespace = 'public'::regnamespace
and c.relkind = 'r'
group by c.relname, c.relrowsecurity
order by c.relrowsecurity, c.relname;
rls_actif = false : la table est ouverte à tous les rôles qui ont un droit dessus, et sur les projets existants une nouvelle table du schéma public reçoit par défaut tous les droits pour anon et authenticated. rls_actif = true avec zéro règle : rien ne passe par l’API, c’est l’état sûr, comme le rappelle la documentation de Postgres (« default deny »).
Que disent les règles ?
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname in ('public', 'storage')
order by tablename, cmd;
La vue pg_policies donne, pour chaque règle, l’opération (cmd), les rôles concernés, la condition sur les lignes existantes (qual, le using) et celle sur les lignes écrites (with_check). Ce qu’il faut repérer :
| Ce que vous lisez | Ce que ça signifie |
|---|---|
qual ou with_check vaut true | la règle laisse tout passer pour ces rôles |
roles vaut {public} | la règle s’applique aussi aux visiteurs anonymes |
{authenticated} avec qual = true | tout inscrit lit ou modifie tout (le cas de l’étape 3) |
une règle INSERT dont with_check ne mentionne pas auth.uid() | on peut créer des lignes au nom d’un autre |
une règle UPDATE sans with_check | Postgres réutilise alors le using pour les lignes écrites : vérifiez qu’il interdit bien de changer de propriétaire |
| deux règles sur la même opération, dont une large | les règles permissives se combinent par un OU : la plus large l’emporte |
une condition qui lit user_metadata | cette donnée est modifiable par l’utilisateur lui-même |
Le cas des deux règles est fréquent dans les applications générées : une règle using (true) ajoutée pour faire disparaître une erreur, puis une règle stricte ajoutée plus tard, sans supprimer la première. L’application semble corrigée ; elle ne l’est pas.
Attention aussi : une opération UPDATE exige une règle SELECT. Une modification qui échoue peut venir d’une règle de lecture manquante, pas d’une protection voulue.
Les vues et les fonctions qui contournent le RLS
Les vues sans l’option security_invoker :
select c.relname as vue, c.reloptions
from pg_class c
where c.relnamespace = 'public'::regnamespace
and c.relkind = 'v';
Toute vue dont reloptions ne contient pas security_invoker=true (ou on) s’exécute avec les droits de son créateur et ignore le RLS des tables qu’elle lit.
Les fonctions security definer exécutables par un visiteur anonyme :
select p.proname as fonction,
has_function_privilege('anon', p.oid, 'execute') as anon_peut_executer
from pg_proc p
where p.pronamespace = 'public'::regnamespace
and p.prosecdef;
Chaque ligne à true est une fonction que n’importe qui peut appeler avec les droits de son créateur : relisez son code, et rejouez-la comme à l’étape 5.
Le Security Advisor
Ouvrez enfin Advisors, puis Security Advisor (adresse directe : supabase.com/dashboard/project/VOTRE-PROJET/advisors/security). Il passe en revue une vingtaine de règles. Celles qui recoupent ce test, d’après la liste officielle :
0013_rls_disabled_in_public(ERROR) : une table du schémapublicsans RLS ;0024_permissive_rls_policy: une règle toujours vraie,using (true)ouwith check (true);0010_security_definer_view(ERROR) : une vue qui contourne le RLS ;0028_anon_security_definer_function_executable: une fonctionsecurity definerappelable sans être connecté ;0015_rls_references_user_metadata(ERROR) : une règle fondée suruser_metadata;0025_public_bucket_allows_listing: un bucket public dont on peut lister le contenu.
En ligne de commande, la même analyse se lance avec supabase db advisors. Le Security Advisor lit la configuration ; il ne sait pas qu’une facture appartient à un client et pas à un autre. Une règle using (auth.uid() is not null) ne déclenche aucune alerte et laisse pourtant chaque inscrit lire toutes les factures. Seul le test de l’étape 3 le voit.
Côté Lovable, le scan qui tourne à chaque publication vérifie la configuration de la base et les règles RLS, et un scan approfondi du code se lance à la demande. Comme le Security Advisor, il contrôle la forme des règles ; aucun des deux ne remplace le test avec deux comptes.
La grille à remplir
Le résultat attendu pour chaque table, vue et fonction sensible ; tout écart est à corriger.
| Test | Anonyme | Compte A sur les données de B |
|---|---|---|
lire (GET) | rien, ou seulement le public voulu | rien |
modifier (PATCH) | refusé | [] |
supprimer (DELETE) | refusé | [] |
créer au nom de B (POST) | refusé | erreur RLS |
| modifier son propre rôle ou plan | sans objet | refusé |
| fonction RPC | refusé, sauf fonction publique | rien sur B |
| fichier de B dans le stockage | inaccessible | inaccessible |
Ce que ce test ne couvre pas
Il vérifie l’accès aux données par l’API de Supabase. Il ne cherche pas une clé secrète dans le code envoyé au navigateur, ne teste pas les Edge Functions, l’authentification ou les sauvegardes : tout cela, et la façon de corriger chaque case rouge de la grille, est dans la check-list de sécurité Lovable et Supabase. Il ne trouve pas non plus les failles de logique métier, comme un montant modifiable avant paiement. Pour l’exploitation (e-mails, base de test, Stripe en live), voyez la check-list de mise en production d’une app Lovable, et pour les défauts récurrents du code généré, ce qu’il faut reprendre sur un MVP codé par IA.
Chez Raicode, nous avons repris et mis en production une douzaine d’applications générées par IA, dont une application RH et une messagerie chiffrée interne d’entreprise où il a fallu corriger de nombreuses failles de sécurité ; cette grille est la première chose que nous remplissons. Si une case est rouge et que vous ne savez pas ce qu’elle entraîne, ou si vous préférez qu’un regard extérieur fasse le test avant l’ouverture, c’est le point de départ de notre audit de reprise d’application Lovable, avec un verdict sous 48 h. Pour décider ensuite qui fera évoluer l’application, lisez continuer seul, prendre un freelance ou passer par un studio.


