Validra
← Tous les articles
SupabaseRow-Level SecurityCode généré par IAContrôle d'accèsPostgres

La RLS est activée. Vos données Supabase sont quand même publiques.

Pourquoi les apps Supabase, codées par l'IA ou par des humains, ont une RLS activée mais qui fuit, comment la détecter avec deux comptes de test, et des politiques sûres testées.

•2026-10-16•8 min de lecture
Données vérifiées & Chiffres clés
Une dizaine (RLS activée, mal configurée)
Cas dans mes propres revues
170 projets Lovable sur 1 645
Analyse publique (Palmer, 2025)
Noté 9,3, marqué contesté
CVE-2025-48757
Un test à deux comptes
Ce qui la détecte
Points clés à retenir

Dans une app Supabase, la clé publique est faite pour être publique : c'est la Row-Level Security qui protège les données. Le problème que je vois le plus n'est pas une RLS désactivée, mais une RLS activée avec des politiques qui vérifient seulement que quelqu'un est connecté. Cet article reproduit le problème sur une base de test locale, montre comment le détecter sans toucher l'app de quelqu'un d'autre, et donne des politiques que j'ai testées, avec les pièges qui m'ont surpris.

1. Le problème

Dans une app Supabase, le navigateur parle directement à la base de données. La clé qu'il utilise, la clé « anon » ou « publishable », est faite pour être publique : elle est dans le JavaScript de chaque visiteur. Ce qui protège les données, c'est la Row-Level Security (RLS) : des politiques Postgres qui décident, ligne par ligne, qui peut lire ou modifier quoi.

Quand je passe en revue des apps, le problème que je rencontre le plus n'est pas une RLS désactivée. C'est une RLS activée, avec des politiques qui ne disent pas ce que le développeur croit. Je l'ai vu une dizaine de fois lors de revues pour des clients. Je ne peux pas les nommer (les revues sont sous NDA), et une dizaine de cas, c'est ce que j'ai vu, pas une mesure de la fréquence du problème.

Le code derrière ces politiques avait été écrit par des outils d'IA (Lovable, Codex, Claude Code) aussi souvent que par des humains. Mon hypothèse, que je n'ai pas testée rigoureusement, c'est que le mécanisme est le même dans les deux cas :

  • L'objectif est un écran qui marche. Une politique trop stricte produit une erreur visible ; une politique trop permissive ne produit rien de visible.
  • L'interface cache le problème. Le frontend filtre par utilisateur connecté, donc tout semble correct à l'écran. La fuite n'apparaît qu'en appelant l'API directement, avec un autre compte.
  • « Sécurisé » est une propriété du système entier. Une politique peut sembler raisonnable isolément et être fausse pour le produit.

En 2025, Matt Palmer a analysé 1 645 projets créés avec Lovable et trouvé 303 endpoints sur 170 d'entre eux (environ 10,3 %) avec une RLS insuffisante, exposant des données qui comprennent, selon ses mots, « subscriptions, names, phone numbers, API keys, payment details » (sa déclaration). C'est devenu le CVE-2025-48757, noté 9,3 par l'organisme qui l'a attribué et marqué contesté : la position de Lovable est que chaque client est responsable de la protection des données de son application.

C'est cette contestation qui m'intéresse. L'outil a généré la politique, le client l'a mise en ligne, et chacun peut dire que l'autre était responsable. Les données ont été exposées dans tous les cas.

Usage défensif uniquement

Tous les exemples ont tourné sur une base de test locale que j'ai créée. Ne testez jamais une application qui ne vous appartient pas ou sans autorisation écrite : un accès non autorisé peut être illégal (par exemple art. 323-1 du Code pénal en France, le CFAA aux États-Unis).

2. Un exemple reproductible

J'ai reproduit le problème sur une base Postgres 16 locale, avec une imitation minimale des fonctions auth.uid() et auth.role() de Supabase et de ses rôles anon / authenticated. C'est une imitation, pas un vrai projet Supabase ; le comportement des politiques est celui de Postgres.

Une table, deux utilisateurs, A et B, chacun avec une note privée. Connecté en tant que B, un select sur notes renvoie les deux notes : la politique vérifie que l'utilisateur est connecté, pas qu'il possède la ligne. N'importe quel compte gratuit lit les données de tout le monde, et rien ne le montre dans l'interface.

SQLValidra Security Pattern
create table notes (id serial primary key, owner_id uuid not null, body text);
alter table notes enable row level security;

-- La politique que je vois le plus : la RLS est activée et "exige une connexion".
create policy "logged in can read" on notes
for select using (auth.role() = 'authenticated');

3. Comment le détecter (en lecture seule)

  • Sans compte. Regardez quelles tables répondent à une requête anonyme. J'ai écrit un outil open source pour ça, validra-check : npx validra-check https://votre-app.com. Il n'envoie que des requêtes GET et n'affiche jamais les valeurs. Il ne trouverait pas l'exemple ci-dessus, car cette fuite demande un compte.
  • Lire les politiques. Listez les tables sans RLS (select tablename from pg_tables where schemaname = 'public' and not rowsecurity;) et lisez chaque politique dans pg_policies. Cherchez true, les politiques qui testent seulement auth.role(), et les politiques INSERT sans contrôle du propriétaire.
  • Deux comptes de test. Connectez-vous en tant que B par le SDK client ou l'API REST et essayez de lire et modifier les lignes de A. C'est la seule étape qui trouve l'exemple ci-dessus. Ne le faites pas dans l'éditeur SQL : il tourne en postgres, qui contourne la RLS.

Lors des revues clients, c'est à l'étape 3 que les vrais problèmes sont apparus : en testant directement l'API du projet, avec l'autorisation du propriétaire, puis en recommandant des politiques plus sûres comme celles ci-dessous.

4. La correction

Ces politiques suivent la documentation RLS de Supabase. Je les ai toutes testées sur la base de test : B ne voit que sa note, ne peut pas créer une note au nom de A, ne peut pas lui transférer la sienne, et une requête anonyme ne reçoit rien.

SQLValidra Security Pattern
-- Lecture : uniquement vos propres lignes.
-- "to authenticated" arrête les requêtes anonymes avant la vérification.
-- "(select auth.uid())" est évalué une fois par requête, pas une fois par ligne.
create policy "owners read their notes" on notes
for select to authenticated
using ((select auth.uid()) = owner_id);

-- Insertion : la nouvelle ligne doit vous appartenir.
create policy "owners create their notes" on notes
for insert to authenticated
with check ((select auth.uid()) = owner_id);

-- Modification : USING choisit les lignes modifiables, WITH CHECK valide le résultat.
create policy "owners update their notes" on notes
for update to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);

create index on notes (owner_id);  -- les politiques filtrent dessus

5. Les pièges que j'ai vérifiés

  • `INSERT ... with check (true)` : testé, B peut créer des lignes au nom de A. Vérifiez toujours le propriétaire à l'insertion.
  • `UPDATE` sans `WITH CHECK` : je pensais que B pourrait transférer ses lignes à A. Ce n'est pas le cas : Postgres réutilise l'expression USING comme contrôle. Écrivez-le quand même, pour que l'intention soit claire pour la prochaine personne, humaine ou modèle, qui modifiera la politique.
  • RLS activée, aucune politique : testé, rien n'est visible. La correction tentante, using (true), ouvre tout.
  • Les vues : testé, une vue normale créée par postgres ignore la RLS de la table. security_invoker = true (Postgres 15+) corrige ça.
  • Une autorisation basée sur `user_metadata` : Supabase la déconseille, car l'utilisateur peut modifier ses propres métadonnées.
  • Les fonctions `SECURITY DEFINER` et la clé `service_role` contournent la RLS par conception. Gardez-les côté serveur et relisez chacune.
  • Le stockage a ses propres politiques, séparées des tables.

6. Liste de contrôle

  • RLS activée sur chaque table du schéma exposé
  • Aucun using (true) ni with check (true) sur des tables avec des données utilisateur
  • Chaque politique nomme son rôle (to authenticated)
  • Les politiques comparent le propriétaire de la ligne à (select auth.uid()), pas seulement « est connecté »
  • Les politiques INSERT et UPDATE ont un WITH CHECK explicite
  • Aucune autorisation basée sur user_metadata
  • Les vues utilisent security_invoker = true
  • Chaque fonction SECURITY DEFINER relue
  • Clés service_role et secrètes jamais dans le code client
  • Un test d'isolation à deux comptes tourne dans la CI
  • Politiques de stockage relues

7. Ce qu'un scanner ne voit pas

Un scanner externe, y compris le mien, ne voit que ce que voit un visiteur anonyme. Il ne peut pas dire si l'utilisateur A peut lire les données de B, si une équipe peut lire les factures d'une autre, ou si une politique techniquement correcte est trop large pour votre produit. Ces questions demandent quelqu'un, ou quelque chose, qui sait comment le produit est censé fonctionner.

8. Ce que je ne sais pas encore

  • La fréquence réelle. Une dizaine de cas dans mes revues et une grande analyse publique. Ce n'est pas un taux.
  • La part due aux outils d'IA. Les cas venaient aussi bien de code généré par IA que de code écrit par des humains ; je ne peux pas séparer les deux.
  • Si les agents de code actuels le font encore. La prochaine expérience : demander à plusieurs agents de construire la même app multi-utilisateurs et mesurer combien de fois leurs politiques par défaut fuient entre comptes. Je ne l'ai pas encore faite.
  • Comment automatiser le test à deux comptes sans risque. Mon outil s'arrête volontairement à l'accès anonyme. Tester automatiquement l'isolation entre utilisateurs connectés, sans que l'outil devienne un outil d'attaque, reste une question ouverte pour moi.

Questions fréquentes

La clé anon de Supabase est-elle secrète ?

Non. Elle est faite pour être publique et se trouve dans le navigateur de chaque visiteur. Ce sont les politiques RLS qui protègent les données ; c'est la clé service_role ou secrète qui ne doit jamais arriver côté client.

Un scanner automatique trouve-t-il une politique mal configurée ?

Seulement si les données sont lisibles sans connexion. Une politique qui laisse n'importe quel utilisateur connecté lire les lignes de tout le monde demande un test à deux comptes.

Une politique UPDATE a-t-elle besoin de WITH CHECK ?

Si vous l'omettez, Postgres réutilise l'expression USING comme contrôle, donc le propriétaire reste vérifié. L'écrire explicitement rend l'intention claire et évite les erreurs lors d'une modification future.

Validra Security Review

Votre SaaS est-il prêt pour la production ?

Identifiez vos failles critiques avant vos utilisateurs avec la Security Review Validra sous 5 jours ouvrés.