Retour
Secrets & données exposées26 sept. 2026 · 10 min de lecture

16 326 bases Supabase lisibles par n'importe qui

par detektd team

Allée d'une salle serveurs plongée dans l'obscurité, baies éclairées de voyants verts et bleus
Photo : The National Archives (UK) · CC BY 3.0 · Wikimedia Commons

Hier, l'entreprise de cybersécurité UpGuard a publié les résultats d'un scan mené sur près de 300 000 domaines identifiés comme utilisant Supabase. Pour les trouver, les chercheurs ont croisé deux sources : l'empreinte technographique de BuiltWith, et le jeu de données Chrome UX Report sur BigQuery, qui permet d'analyser les fichiers JavaScript réellement servis par des millions de sites. Dans ces bundles publics, ils ont cherché l'URL du projet Supabase et sa clé « anon », puis interrogé chaque base pour une table users lisible sans authentification.

Résultat : 16 326 bases renvoient des lignes à quiconque envoie la requête. Pas d'identifiant, pas de jeton, pas d'exploit : une requête HTTP standard, la même que celle qu'envoie l'application elle-même. Plus de la moitié de ces bases contiennent des indices de données personnelles (noms, adresses, téléphones). Une part plus faible expose des mots de passe ou des jetons d'authentification, et un très petit nombre, des données de carte bancaire plausibles.

0

bases Supabase aux tables lisibles publiquement, sur ~300 000 domaines analysés (UpGuard, 25 sept. 2026)

Un point de méthode mérite d'être souligné : UpGuard n'a pas parcouru les données des utilisateurs. L'équipe a analysé la structure des tables exposées (leurs colonnes) pour qualifier le type d'information présente, plutôt que de lire les lignes une à une. C'est la bonne façon de mesurer une exposition de cette taille sans devenir soi-même un problème de confidentialité.

Comment Supabase décide qui peut lire quoi

Pour comprendre l'ampleur du problème, il faut comprendre l'architecture. Supabase expose chaque base Postgres à travers PostgREST, une API REST générée automatiquement à partir du schéma : chaque table du schéma public devient un endpoint, interrogeable directement depuis le navigateur. Le frontend s'authentifie avec deux éléments conçus pour être publics : l'URL du projet et la clé « anon » (renommée clé « publishable » dans le nouveau format de clés introduit par Supabase en 2025). Cette clé n'est pas un secret. Elle se retrouve forcément dans le JavaScript envoyé au navigateur, et c'est normal.

Ce qui protège les données, ce n'est donc pas la clé : c'est Postgres lui-même. Une requête portant la clé anon s'exécute sous le rôle Postgres anon ; une requête d'utilisateur connecté, sous le rôle authenticated. Et c'est Row Level Security (RLS) qui décide, ligne par ligne, ce que chacun de ces rôles a le droit de voir ou de modifier. Quand RLS est active sur une table sans aucune policy, personne ne voit rien. Quand RLS est désactivée, les droits par défaut du schéma public s'appliquent, et ils autorisent ces rôles à lire et écrire.

Clé anon exposée côté navigateur (normal)
PostgREST exécute la requête sous le rôle anon
Table créée sans RLS
Lecture et écriture illimitées

Un problème de chemin, pas de fonctionnalité

La cause profonde documentée par UpGuard n'a rien d'exotique. Supabase active RLS par défaut quand une table est créée depuis l'interface Table Editor du dashboard, mais pas quand elle est créée par une migration SQL ou via l'API. Or c'est exactement ce chemin qu'empruntent les agents de code IA (Claude Code, Cursor, Lovable, Bolt...) quand ils scaffoldent un schéma : ils écrivent du SQL, ils ne cliquent pas dans un dashboard.

Le piège est d'autant plus efficace qu'il est invisible. Une table sans RLS fonctionne parfaitement : l'app affiche ses données, les formulaires enregistrent, les tests passent. Rien, dans un test fonctionnel, ne distingue « l'utilisateur voit ses données » de « tout le monde voit les données de tout le monde ». Pire, le chemin inverse pousse dans la mauvaise direction : si l'agent active RLS sans écrire de policy, l'app renvoie soudain des listes vides, et le correctif le plus rapide pour « faire marcher l'app » consiste à désactiver RLS, ou à ajouter une policy using (true) qui autorise tout.

« Exposition systémique de données dans les applications Supabase. »

Titre du rapport UpGuard, 25 septembre 2026

Moltbook : le cas d'école

L'exemple le plus cité reste Moltbook, un réseau social pour agents IA lancé le 28 janvier 2026 et entièrement vibecodé : son créateur a expliqué publiquement n'avoir écrit aucune ligne de code lui-même. Le projet est devenu viral en quelques jours. Les chercheurs de Wiz ont alors mené une revue non intrusive, en naviguant sur la plateforme comme un utilisateur ordinaire, et ont trouvé la clé Supabase dans le JavaScript du site (ce qui, on l'a vu, est normal), mais sans aucune policy RLS derrière.

Conséquence : un accès complet, en lecture comme en écriture, à toute la base de production. 1,5 million de jetons d'authentification API, 35 000 adresses email, et les messages privés échangés entre agents. L'accès en écriture est le détail le plus inquiétant : il permettait non seulement de lire, mais de modifier le contenu publié par n'importe quel agent. Wiz a signalé le problème et l'a corrigé avec l'équipe de Moltbook en quelques heures ; les données consultées pendant la recherche ont été supprimées.

Moltbook n'est pas isolé, c'est simplement le plus visible. Le rapport UpGuard documente d'autres bases exposées, avec des volumes tout aussi concrets, et surtout des profils de victimes qui n'ont rien de « projets de démo » :

  • Un service de voiturier : plus de 100 000 clients, dont 43 000 avec nom et email et 78 000 avec plaque d'immatriculation, plus une table de 665 employés (emails, téléphones, jetons de notification push).
  • Un consulat africain : 25 000 utilisateurs, adresses et localisations de logements d'urgence incluses.
  • Un service d'immigration canadien : 5 000 dossiers, dont 884 avec un mot de passe stocké en clair.

Cinq façons de rater RLS

Activer RLS ne suffit pas. Les analyses publiées cette année sur les apps générées par IA distinguent cinq modes d'échec, du plus grossier au plus subtil :

  1. RLS désactivée : la table est entièrement ouverte aux rôles anon et authenticated. C'est le cas mesuré par UpGuard.
  2. Policy permissive : RLS est active, mais une policy using (true) autorise tout le monde. La case est cochée, la protection est nulle.
  3. Couverture partielle : la lecture est protégée, mais pas l'insertion ou la mise à jour : n'importe qui peut écrire des lignes au nom d'un autre utilisateur.
  4. Clé service_role exposée : cette clé contourne RLS par conception. Si elle se retrouve dans le bundle client, aucune policy ne protège plus rien.
  5. Mauvais usage de auth.uid() : une policy qui compare à une colonne que l'utilisateur contrôle lui-même, ou qui oublie qu'un utilisateur anonyme a un uid nul.

À quoi ressemble le correctif

RLS s'active table par table, et chaque policy doit correspondre à un vrai modèle d'accès. Le minimum viable pour une table possédée par un utilisateur couvre à la fois la lecture et l'écriture. La clause with check est ce qui empêche d'insérer ou de modifier une ligne au nom de quelqu'un d'autre :

Lecture et écriture limitées au propriétaire
alter table public.projects enable row level security;

create policy "select own projects"
  on public.projects for select
  to authenticated
  using ((select auth.uid()) = owner_id);

create policy "insert own projects"
  on public.projects for insert
  to authenticated
  with check ((select auth.uid()) = owner_id);

create policy "update own projects"
  on public.projects for update
  to authenticated
  using ((select auth.uid()) = owner_id)
  with check ((select auth.uid()) = owner_id);

Deux détails comptent. La clause to authenticated exclut explicitement les visiteurs anonymes. Et l'écriture (select auth.uid()) plutôt que auth.uid() est la forme recommandée par la documentation Supabase : elle permet à Postgres d'évaluer la fonction une seule fois par requête plutôt qu'une fois par ligne.

Sous le capot : du JWT au rôle Postgres

Pour écrire une bonne policy, il faut savoir ce que Postgres « voit » au moment où elle s'exécute. Chaque requête qui arrive sur PostgREST porte un JWT : la clé anon elle-même pour un visiteur, ou le jeton de session pour un utilisateur connecté. PostgREST vérifie sa signature, lit le claim role et bascule la transaction sur ce rôle Postgres (anon ou authenticated). Il dépose ensuite l'ensemble des claims dans un paramètre de session, request.jwt.claims. La fonction auth.uid() de Supabase ne fait rien d'autre que relire le claim sub dans ce paramètre :

Ce que fait auth.uid() (version simplifiée)
select (current_setting('request.jwt.claims', true)::jsonb ->> 'sub')::uuid;

-- anon visitor: no sub claim -> NULL
-- NULL = owner_id is never true, so a policy on auth.uid() denies anon by construction

Reste la question des droits. Sur Supabase, les tables créées dans le schéma public reçoivent par défaut des GRANT pour les rôles anon et authenticated : sans RLS, ces droits s'appliquent tels quels. On peut ajouter une deuxième ligne de défense, indépendante des policies, en retirant explicitement les droits du rôle anon sur les tables qui ne doivent jamais être lues sans connexion :

Défense en profondeur : aucun droit pour anon
revoke all on table public.projects from anon;

-- policies are only evaluated for roles that hold the privilege in the first place

Les angles morts : vues, fonctions, stockage

Même avec RLS correctement activée sur chaque table, trois objets peuvent la contourner sans que personne ne s'en rende compte. Le premier, ce sont les vues. Par défaut, une vue Postgres s'exécute avec les droits de son propriétaire : sur Supabase, le rôle postgres, qui n'est pas soumis à RLS. Une vue « résumé » posée sur une table protégée devient donc une porte dérobée vers toutes ses lignes. Depuis Postgres 15, l'option security_invoker force la vue à s'exécuter avec les droits de l'appelant, et donc à respecter ses policies.

Une vue qui respecte RLS
create view public.project_summaries
  with (security_invoker = true) as
select id, name, created_at
from public.projects;

Le deuxième, ce sont les fonctions. Toute fonction du schéma public est appelable via l'API, à l'adresse /rest/v1/rpc/<nom>. Une fonction déclarée SECURITY DEFINER s'exécute avec les droits de son créateur et ignore donc RLS : c'est utile pour des opérations contrôlées, dangereux si elle est appelable par anon. Deux règles : fixer son search_path pour qu'elle ne résolve pas des objets choisis par l'appelant, et retirer le droit d'exécution aux rôles qui n'en ont pas besoin.

Une fonction privilégiée, verrouillée
create or replace function public.admin_stats()
returns table (users bigint, projects bigint)
language sql
security definer
set search_path = ''
as $$
  select (select count(*) from auth.users), (select count(*) from public.projects);
$$;

revoke execute on function public.admin_stats() from public, anon, authenticated;

Le troisième, c'est le stockage de fichiers. Supabase Storage range les métadonnées des fichiers dans une table, storage.objects, protégée elle aussi par RLS. Mais un bucket marqué « public » sert ses fichiers à quiconque connaît leur URL, indépendamment des policies. Et une policy de lecture trop large sur storage.objects permet de lister le contenu d'un bucket, donc de découvrir les URL. Factures, pièces d'identité, exports : ce qui est rangé dans un bucket public est public.

Tester une policy dans la peau d'un utilisateur

Le SQL Editor de Supabase s'exécute avec un rôle privilégié : une requête qui y renvoie des lignes ne prouve rien sur ce que verra un utilisateur. On peut en revanche reproduire exactement ce que fait PostgREST, dans une transaction qu'on annule à la fin : changer de rôle, déposer des claims, et interroger la table.

Simuler un utilisateur, puis un visiteur anonyme
begin;
  set local role authenticated;
  set local request.jwt.claims = '{"sub": "<user-uuid>", "role": "authenticated"}';
  select count(*) from public.projects;  -- only this user's rows
rollback;

begin;
  set local role anon;
  select count(*) from public.projects;  -- expected: 0, or a permission error
rollback;

Dernier détail, côté performance : une policy du type (select auth.uid()) = owner_id est évaluée pour chaque ligne candidate. Sans index sur owner_id, chaque requête d'un utilisateur parcourt toute la table. Un simple create index on public.projects (owner_id) rend la policy à la fois sûre et rapide.

Vérifier son propre projet en deux minutes

La première vérification se fait côté base. Postgres expose directement l'état de RLS pour chaque table ; cette requête, à lancer dans le SQL Editor de Supabase, liste toutes les tables du schéma public où elle est désactivée :

Tables publiques sans RLS
select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

La seconde se fait de l'extérieur, exactement comme le ferait n'importe quel visiteur : envoyer à votre propre API la requête que votre frontend envoie, avec la seule clé publique, sans être connecté. Si elle renvoie des lignes qui ne devraient pas être publiques, le problème est réel. Le Security Advisor du dashboard Supabase signale aussi les tables du schéma public sans RLS, un avertissement qui mérite d'être traité comme une erreur, pas comme une suggestion.

Tester sa propre table sans être connecté
curl "https://<project-ref>.supabase.co/rest/v1/projects?select=*&limit=1" \
  -H "apikey: <your-anon-or-publishable-key>"

# expected for private data: []

Si vous utilisez un agent IA

Ajoutez la règle à votre fichier d'instructions (CLAUDE.md, .cursorrules...) : toute table créée doit activer RLS et définir des policies explicites pour select, insert, update et delete. Et ne laissez jamais l'agent « corriger » une liste vide en désactivant RLS.

C'est exactement ce que le check RLS de detektd vérifie sur chaque scan d'une app détectée comme Supabase : si la clé publique exposée permet de lire ou d'écrire des tables qui n'ont pas de policy correspondant à leur usage réel, depuis l'extérieur, comme le ferait n'importe qui.

16 326 bases Supabase lisibles par n'importe qui | detektd