SupabaseContrôle d'accèsPostgres

Comment vérifier la row level security de ton projet Supabase comme le ferait un attaquant

Supabase remet à chaque visiteur de ton app une clé de ta base de données. C'est voulu : la clé anon (désormais appelée « publishable ») se trouve dans ton bundle front-end, et n'importe qui peut la copier depuis les outils de développement du navigateur en quelques secondes. Ce qui sépare cette clé des données de tes utilisateurs, c'est la row level security (RLS), une fonctionnalité de Postgres qui décide, ligne par ligne, qui a le droit de lire ou de modifier quoi.

Quand la RLS manque ou qu'elle est écrite de façon trop permissive, la clé publique suffit pour aspirer une table entière. Ce guide revient d'abord sur le cas qui a rendu tout ça très concret en 2025, puis sur les quatre vérifications que je fais sur n'importe quel projet Supabase avant qu'il accueille de vrais utilisateurs.

Ce que CVE-2025-48757 a montré

En mars 2025, Matt Palmer, ingénieur chez Replit, s'est penché sur les apps présentées dans la vitrine publique de Lovable. Il s'est contenté de visiter leur page d'accueil, sans se connecter et sans rien forcer. Sur 1 645 apps, 170 projets exposaient des données à travers 303 endpoints, soit environ 10,3 % de l'échantillon.

Les tables exposées contenaient des données personnelles comme des noms et des adresses e-mail, des clés d'API tierces (Google Maps, Gemini, eBay), des transactions et des abonnements. L'accès ne se limitait pas à la lecture : il a réussi à passer le payment_status d'un enregistrement à "paid" sans qu'aucun paiement ne passe par Stripe.

La chronologie de son rapport vaut la peine d'être connue :

  • 20 et 21 mars : première app vulnérable trouvée, scan plus large, e-mail à Lovable.
  • 14 avril : un autre ingénieur, Danial Asaria, montre publiquement une extraction de données en 47 minutes.
  • 24 avril : Lovable 2.0 sort avec un « scan de sécurité ». D'après le rapport, il vérifiait qu'une policy existait, pas qu'elle protégeait réellement quoi que ce soit.
  • 29 mai : divulgation publique.

La fiche de la NVD note la faille à 9,3, critique. Lovable conteste la CVE, au motif que la sécurité des données de chaque app relève de la responsabilité de son créateur. Quoi que tu penses de cet argument, la leçon pratique est la même pour tout projet Supabase, construit avec une IA ou non : la clé publique n'a jamais été le bug. Les policies, si.

Étape 1 : vérifie que la RLS est activée sur chaque table

Les tables que tu crées depuis le tableau de bord Supabase ont la RLS activée par défaut. Celles créées en SQL, via des migrations, un ORM ou un outil d'IA qui écrit du SQL brut, ne l'ont pas. Cet écart est la façon la plus courante dont une table finit grande ouverte.

Liste les tables de ton schéma exposé dont la RLS est encore désactivée :

select relname as table_name
from pg_class
join pg_namespace on pg_namespace.oid = pg_class.relnamespace
where nspname = 'public'
  and relkind = 'r'
  and not relrowsecurity;

Chaque ligne du résultat est à corriger :

alter table public.invoices enable row level security;

Activer la RLS sans écrire de policy ne présente aucun risque : Postgres refuse alors toutes les requêtes venant des rôles de l'API, et la table ne renvoie rien tant que tu n'as pas décidé qui peut voir quoi. Tout bloquer par défaut, c'est exactement ce que tu veux pendant que tu écris tes policies.

Étape 2 : remplace les policies ouvertes à tous par un contrôle du propriétaire

Une policy qui contient using (true) laisse tout le monde lire toutes les lignes. Sa cousine à peine plus présentable, to authenticated using (true), laisse n'importe quel inscrit lire les données de tous les autres comptes, et l'inscription est gratuite.

Une policy doit relier chaque ligne à la personne qui fait la demande. Pour une table où chaque ligne a un user_id :

create policy "Owners read their invoices"
  on public.invoices for select
  to authenticated
  using ( (select auth.uid()) = user_id );

create policy "Owners update their invoices"
  on public.invoices for update
  to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

Deux détails comptent ici. La clause with check empêche quelqu'un de modifier une ligne puis de la réattribuer à un autre utilisateur. Et entourer auth.uid() d'un select est la forme que recommande la documentation de Supabase, parce que Postgres peut alors l'évaluer une seule fois par requête au lieu d'une fois par ligne.

Certaines colonnes ne devraient jamais pouvoir être modifiées depuis le navigateur : statut de paiement, formule, rôle, solde de crédits. Garde-les hors de portée du rôle authenticated, par exemple en retirant le droit d'update sur ces colonnes, et ne les modifie que depuis du code côté serveur qui a vérifié un webhook Stripe ou une action d'administrateur.

Étape 3 : interroge ton propre projet comme un inconnu

Lire tes policies, ce n'est pas les tester. Fais ce qu'a fait Palmer, sur ton propre projet uniquement : appelle l'API REST avec rien d'autre que la clé publique.

curl 'https://YOUR_PROJECT.supabase.co/rest/v1/invoices?select=*' \
  -H 'apikey: YOUR_PUBLISHABLE_KEY'

Un tableau vide ou une erreur de permission, c'est le résultat que tu veux. Si des lignes reviennent, chaque visiteur de ton site peut les obtenir aussi. Répète l'appel pour chaque table, et tente aussi une écriture (un PATCH sur une de tes propres lignes de test) pour confirmer que les modifications anonymes échouent.

Teste ensuite le cas que la plupart des audits oublient : deux vrais comptes. Connecte-toi avec l'utilisateur A et crée quelques données. Connecte-toi avec l'utilisateur B dans un autre profil de navigateur et, depuis la console de développement, essaie de récupérer les lignes de A par leur ID, ou rejoue une des requêtes de A avec la session de B. Si B voit quoi que ce soit qui appartient à A, une policy vérifie que quelqu'un est connecté, et non qui il est.

Étape 4 : fais tourner tout ce qui a été exposé

Si une table a été ouverte à un moment quelconque pendant que l'app était en ligne, considère que son contenu a été copié. Toute clé d'API stockée dans cette table est compromise tant qu'elle n'a pas été régénérée auprès du fournisseur qui l'a émise, que ce soit Google, OpenAI, Stripe ou n'importe qui d'autre. Préviens les utilisateurs concernés si des données personnelles étaient en jeu ; selon l'endroit où ils vivent, tu peux aussi avoir des délais légaux à respecter.

Vérifie aussi que ta clé secrète, qui commence par sb_secret_ (l'ancien nom était service_role), n'apparaît nulle part dans le code front-end. La documentation de Supabase est explicite : cette clé contourne toutes les policies de row level security. Cherche-la dans ton JavaScript compilé, pas seulement dans tes fichiers sources, parce que des variables d'environnement peuvent se retrouver dans les bundles au moment du build. Sur les projets plus anciens, les clés sont des JWT qui commencent par eyJ, et le rôle est encodé à l'intérieur : chercher le mot service_role ne donne rien, même quand cette clé est exposée. Décode la partie centrale de chaque chaîne eyJ… avec atob() dans la console du navigateur ; "role":"anon" est normal, "role":"service_role" ne l'est pas.

Laisse le conseiller de Supabase faire un second passage

Dans le tableau de bord, le Security Advisor signale les tables sans RLS et plusieurs autres réglages risqués. C'est un filet de sécurité utile après les vérifications manuelles ci-dessus, pas un substitut : comme le scan de Lovable décrit dans le rapport, un linter peut te dire qu'une policy existe, pas si elle traduit la bonne règle pour ton app.

Un changement de la plateforme mérite aussi d'être suivi. Depuis le 30 mai 2026, les nouveaux projets n'exposent plus à la Data API les tables nouvellement créées sans autorisation explicite (grant), et les projets existants suivent le 30 octobre 2026. Ça réduit les expositions accidentelles, mais une fois qu'une table a reçu ce grant, ses lignes ne sont toujours filtrées que par tes policies RLS.

FAQ

La clé anon de Supabase peut-elle vraiment aller dans mon front-end ?

Oui. La clé anon ou publishable est conçue pour être publique et identifie ton projet, pas un utilisateur privilégié. Tes données sont protégées par les policies de row level security : la clé n'est donc sûre que si chaque table exposée a des policies correctes.

Quelle est la différence entre la clé anon et la clé secrète ?

La clé anon ou publishable exécute les requêtes avec le rôle limité anon ou authenticated, soumis à tes policies. La clé secrète, anciennement appelée service_role, contourne entièrement la row level security et ne doit jamais vivre ailleurs que sur un serveur.

À quelle fréquence relancer ces vérifications ?

Après chaque migration qui crée une table ou modifie une policy, et avant chaque lancement. Les tables ajoutées dans l'urgence, surtout en SQL brut ou via des migrations générées par une IA, sont celles où la RLS est le plus souvent oubliée.

Sources