Supabase RLS : sécuriser ton app codée avec l'IA
Row Level Security (RLS) est le mécanisme de PostgreSQL qui filtre les lignes d'une table selon l'utilisateur qui la lit. Sans lui, une table exposée dans le schéma public de Supabase peut être lue et modifiée par n'importe qui possédant les droits, y compris un visiteur non connecté. Voici comment l'activer, écrire tes policies et fermer les accès que ton agent de code a laissés ouverts.

Sommaire
- Ce qui fuit quand RLS n'est pas activé
- Les trois notions à comprendre avant d'écrire du SQL
- Activer RLS sur une table
- Écrire tes premières policies
- Clé anon, utilisateur connecté, service_role : qui voit quoi
- Les 6 erreurs qui laissent ta base ouverte
- Le prompt à coller dans ton agent
- Questions fréquentes
- Faut-il activer RLS sur toutes les tables ?
- La clé anon est-elle dangereuse ?
- Pourquoi ma requête renvoie une erreur 42501 ?
- RLS ralentit-il les requêtes ?
- Une vue SQL protège-t-elle mes données ?
- Ferme la porte avant le lancement
Photo de Kevin Ache sur Unsplash
Ce qui fuit quand RLS n'est pas activé
Tu viens de générer une app avec Lovable, Bolt ou Claude Code. Ton agent a créé une table profiles, une table todos, une table projects, et il a branché Supabase. L'app marche, tu la mets en ligne.
Le problème, c'est que la sécurité ne se voit pas dans l'interface. Une table créée dans le schéma public de Supabase peut recevoir, selon la configuration du projet, tous les privilèges par défaut : select, insert, update et delete sont accordés aux rôles anon et authenticated. anon, c'est le rôle des visiteurs non connectés. authenticated, c'est le rôle des utilisateurs connectés.
Concrètement, ça veut dire qu'un inconnu peut, sans se connecter, taper une requête et lire toute ta table profiles : emails, noms, parfois plus. Et selon les privilèges accordés, il peut aussi écrire dedans. C'est la faille la plus fréquente des apps générées par IA : l'agent écrit le code qui fonctionne, mais il ne pense pas à fermer la base.
RLS règle ce problème à la racine. Au lieu de compter sur ton frontend pour ne pas afficher certaines données, la règle vit dans la base elle-même. Même si quelqu'un contourne ton interface, la base refuse.
Les trois notions à comprendre avant d'écrire du SQL
RLS s'appuie sur trois concepts qui se cumulent. Si tu n'en maîtrises qu'un seul, ta base reste ouverte.
Les grants (privilèges). Ils décident si un rôle peut exécuter une opération sur une table : lire (select), insérer (insert), modifier (update), supprimer (delete). Un grant manquant renvoie une erreur 42501 avant même que la moindre policy soit évaluée.
Les policies. Ce sont les règles fines, écrites en SQL, qui décident quelles lignes l'opération concerne. Une policy de lecture sur todos peut dire « tu ne vois que les lignes où user_id est égal à ton identifiant ». Techniquement, c'est une clause where que PostgreSQL ajoute automatiquement à chaque requête.
Les rôles. Supabase mappe chaque requête sur un rôle : anon (non connecté), authenticated (connecté) et service_role (accès complet côté serveur, qui contourne RLS).
L'erreur classique est de créer des policies sans toucher aux grants. Or les deux se cumulent : PostgreSQL vérifie d'abord le grant, puis la policy. Une table protégée uniquement par des policies laisse toujours une porte d'écriture ouverte si les grants par défaut n'ont pas été révoqués. Ajouter une policy ne retire pas les grants existants.
Activer RLS sur une table
La première étape est d'activer RLS sur chaque table du schéma public :
alter table public.todos enable row level security;
Active-le sur toutes les tables, pas seulement celles qui te semblent sensibles. Une table d'analytics ou de préférences peut contenir des données que tu n'as pas envie de voir en accès libre.
Ensuite, retire les privilèges par défaut, puis réaccorde uniquement ce que ton app utilise réellement :
revoke all on public.todos from anon, authenticated;
grant select, insert, update, delete on public.todos to authenticated;
Ici, anon ne peut plus rien faire. Seuls les utilisateurs connectés peuvent lire et écrire, et encore, sous réserve des policies que tu vas écrire juste après. Si ton app a besoin qu'un visiteur non connecté lise une donnée publique (une page de profil publique, par exemple), accorde-lui select explicitement, rien de plus.
Écrire tes premières policies
Une policy de base relie chaque ligne à l'utilisateur qui la possède. La fonction auth.uid() renvoie l'identifiant de l'utilisateur connecté, extrait du jeton d'authentification.
create policy "select own todos"
on public.todos
for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "insert own todos"
on public.todos
for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "update own todos"
on public.todos
for update
to authenticated
using ( (select auth.uid()) = user_id );
create policy "delete own todos"
on public.todos
for delete
to authenticated
using ( (select auth.uid()) = user_id );
Deux clauses à retenir :
usingfiltre les lignes déjà existantes que la requête peut voir, modifier ou supprimer. C'est la condition de lecture.with checkvalide les lignes nouvelles ou modifiées au moment de l'insertion ou de la mise à jour. Sans elle, un utilisateur pourrait insérer une ligne avec leuser_idd'un autre.
Photo de Gorilla ROI Data Connector sur Unsplash
Une policy select sans with check est normale : on ne « vérifie » pas une lecture. Une policy insert sans with check est, elle, presque toujours une erreur.
Pour la performance, ajoute un index sur la colonne que tes policies filtrent en priorité, user_id dans cet exemple :
create index todos_user_id_idx on public.todos (user_id);
Sans index, chaque requête balaye la table entière pour appliquer la policy, et ça devient lent dès quelques milliers de lignes.
Clé anon, utilisateur connecté, service_role : qui voit quoi
Tu as sans doute remarqué deux clés dans les réglages de ton projet : la clé anon et la clé service_role. Beaucoup de vibecodeurs confondent les deux, et c'est une source de fuite majeure.
La clé anon est conçue pour être embarquée dans le frontend. Elle est publique par nature : n'importe qui peut la lire dans le JavaScript de ton site. Elle n'est pas dangereuse en soi, à condition que ta base soit bien protégée par RLS. C'est RLS qui fait le vrai travail de sécurité, pas le secret de la clé.
La clé service_role contourne RLS. Elle lit et écrit tout, sur toutes les tables. Elle ne doit exister que côté serveur : dans une fonction serverless, une route API protégée, un job en arrière-plan. Si tu la colles dans une variable NEXT_PUBLIC_, dans un fichier envoyé au navigateur ou dans le code d'une app Lovable, tu donnes à tout le monde les clés de la base entière.
Entre les deux, le rôle authenticated correspond à un utilisateur connecté via Supabase Auth. Ses droits passent par tes policies. C'est lui le cœur du modèle : anon pour le strict minimum public, authenticated pour les données par utilisateur, service_role pour le serveur uniquement.
Les 6 erreurs qui laissent ta base ouverte
1. Créer des policies sans révoquer les grants. Comme vu plus haut, les deux se cumulent. Si anon a toujours insert sur une table, une policy de lecture ne l'empêchera pas d'écrire dedans. Révise d'abord les privilèges, puis les policies.
2. Utiliser using (true) sans réfléchir. using (true) signifie « tout le monde voit toutes les lignes ». C'est valable pour une donnée réellement publique, comme un article de blog. C'est une catastrophe sur une table de comptes, de commandes ou de messages.
3. Laisser service_role fuiter dans le frontend. C'est la fuite la plus grave qui soit. Une seule requête suffit pour vider ou écraser une base. Vérifie aussi les anciens commits : si la clé a été poussée dans Git, elle reste dans l'historique même après suppression.
4. Oublier les buckets de stockage. Les fichiers que tu héberges sur Supabase Storage sont protégés par les mêmes principes, via la table storage.objects. Un bucket public expose ses fichiers à tous. Un bucket privé sans policy empêche tout le monde, y compris tes utilisateurs légitimes. Traite les buckets exactement comme les tables.
5. Ne pas indexer les colonnes de filtre. RLS ajoute une condition à chaque requête. Sans index sur user_id ou tenant_id, tes requêtes ralentissent et ton app passe pour lente alors qu'elle est juste mal sécurisée.
6. Penser que le frontend suffit. Masquer un bouton « supprimer » dans l'interface ne protège rien. Un inconnu ne passe pas par ton interface : il envoie une requête directement à l'API de Supabase. La seule défense fiable est dans la base.
Le prompt à coller dans ton agent
Tu n'as pas besoin d'écrire tout ça à la main. Colle ce prompt dans Cursor, Claude Code ou Lovable, en remplaçant les noms de tables par les tiens :
Tu es un expert PostgreSQL et Supabase. Sur mon projet, sécurise l'accès aux données avec Row Level Security (RLS).
Pour chaque table du schéma `public` (profiles, todos, projects, ...) :
1. Active RLS : `alter table public.<table> enable row level security;`
2. Révoque les privilèges par défaut : `revoke all on public.<table> from anon, authenticated;`
3. Réaccorde uniquement ce que l'app utilise : `grant select, insert, update, delete on public.<table> to authenticated;`
4. Crée une policy par opération qui relie chaque ligne à l'utilisateur connecté :
- select / update / delete : `using ( (select auth.uid()) = user_id )`
- insert : `with check ( (select auth.uid()) = user_id )`
5. Ajoute un index sur la colonne de filtre (`user_id` ou `tenant_id`) pour chaque table.
6. Garde la clé `service_role` uniquement côté serveur. Ne l'ajoute jamais dans du code frontend ni dans une variable d'environnement publique.
Vérifie ensuite que :
- un utilisateur connecté ne peut lire et modifier que ses propres lignes,
- un visiteur non connecté (`anon`) n'accède à aucune ligne sensible,
- aucune table n'utilise `using (true)` sans que ce soit une donnée réellement publique,
- les buckets de stockage ont eux aussi des policies sur la table `storage.objects`.
Photo de Emile Perron sur Unsplash
Si tu préfères laisser ton agent faire le travail sans copier-coller, le serveur MCP de Rustine se branche directement sur ton agent de code : il scanne ton projet et corrige lui-même les accès trop ouverts.
Questions fréquentes
Faut-il activer RLS sur toutes les tables ?
Oui. RLS doit être activé sur chaque table du schéma public, même celles qui te paraissent anodines. Une table non protégée est lisible et modifiable par tout rôle qui possède les droits dessus, et les données secondaires sont souvent celles qu'on oublie.
La clé anon est-elle dangereuse ?
Non, si ta base est correctement protégée par RLS. La clé anon est conçue pour être publique et embarquée dans le frontend. Ce qui protège tes données, c'est RLS, pas le secret de la clé. En revanche, la clé service_role, qui contourne RLS, doit rester strictement côté serveur.
Pourquoi ma requête renvoie une erreur 42501 ?
L'erreur 42501 signifie qu'un privilège (grant) est manquant. Elle survient avant l'évaluation des policies. Si une requête échoue alors que ta policy devrait l'autoriser, vérifie les grants sur la table avant de toucher à la policy : le rôle n'a probablement pas le droit d'effectuer l'opération.
RLS ralentit-il les requêtes ?
RLS ajoute une condition à chaque requête, ce qui a un coût. Il se contrôle facilement : indexe les colonnes utilisées dans tes policies (user_id, tenant_id), et évite les jointures complexes dans les expressions de policy. Sans index, le filtrage balaye toute la table et devient lent.
Une vue SQL protège-t-elle mes données ?
Non. Par défaut, les vues contournent RLS, car elles sont souvent créées avec le rôle postgres. Si tu exposes une vue sur une table sensible, protège-la explicitement ou vérifie qu'elle ne fuit pas plus que la table sous-jacente.
Ferme la porte avant le lancement
RLS est la protection la plus rentable que tu peux mettre en place sur Supabase : elle vit dans la base, elle ne dépend pas de ton frontend, et elle se résume à trois gestes (activer, révoquer les grants, écrire des policies). C'est aussi la chose que les agents de code oublient le plus souvent, parce qu'elle ne se voit pas dans l'interface.
Avant de mettre ton app en ligne, scanne-la avec Rustine. Il lit ton site comme le ferait un inconnu, te montre ce qui fuit avec la preuve — la requête et ce qu'elle renvoie — et te donne le correctif à coller. Le premier scan est gratuit, sur rustine.io.