Aller au contenu

Supabase RLS policy : exemples testés via l’API

Voir en MarkdownOuvrir dans ClaudeOuvrir dans ChatGPTOuvrir dans Perplexity

La question est aussi copiée. Si le chat reste vide, collez-la.

TL;DR

Voici les politiques RLS dont la plupart des applications Supabase ont besoin, chacune exécutée via l’API REST en invité, en propriétaire de la ligne et en autre utilisateur connecté. Trois résultats valent d’être connus avant de copier quoi que ce soit : une mise à jour sur la ligne d’un autre renvoie 200 avec une liste vide, pas une erreur ; une politique d’insertion d’apparence anodine sur une table d’adhésions a permis à un utilisateur de rejoindre une autre entreprise et de lire ses projets ; et écrire (select auth.uid()) au lieu de auth.uid() a fait passer une requête de 156 ms à 7,4 ms sur 200 000 lignes.

Je suis Denys, ingénieur full-stack et IA, et la plupart des applications que je construis ou répare tournent sur Supabase. Les extraits de Supabase RLS policy sont partout ; ce qu’ils font quand une vraie requête les atteint, on le montre rarement. Je les ai donc exécutés.

Comment j’ai testé. La plupart des requêtes sont passées par une pile Supabase locale (CLI 2.109.1, Postgres 17.6, PostgREST 14.14, Auth 2.192.0) avec la clé publishable et deux vrais utilisateurs connectés, A et B. Les cas qui n’exigent pas de HTTP, comme les erreurs de syntaxe et les mesures de temps, ont tourné sur un simple Postgres 17.10 local avec les rôles anon et authenticated de Supabase et auth.uid() recréé. Le tout le 24 octobre 2026, avec des lignes inventées.

Avant toute politique : la table a-t-elle un grant ?

Une politique décide quelles lignes un rôle voit. Un grant décide si le rôle atteint la table, et sur les projets Supabase actuels les nouvelles tables ne reçoivent plus automatiquement de grants pour la Data API (à partir du 30 octobre 2026 pour tous les projets). Sur mon stack, une table avec la RLS activée et sans grant a répondu :

Requête Statut Corps
Invité GET /invoices 401 permission denied for table invoices, avec l’indication GRANT SELECT ... TO anon
Utilisateur A GET /invoices 403 la même, avec l’indication pour authenticated

Première étape pour chaque table que l’application lit depuis le navigateur :

alter table public.todos enable row level security;
grant select, insert, update, delete on public.todos to authenticated;

N’accordez que les commandes dont le navigateur a besoin, et seulement à authenticated, sauf si les invités doivent lire la table.

Quelle Supabase RLS policy ne laisse voir que ses propres lignes ?

Une politique par commande, chacune limitée aux utilisateurs connectés :

create policy "read own" on public.todos
  for select to authenticated using ((select auth.uid()) = user_id);
create policy "add own" on public.todos
  for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "edit own" on public.todos
  for update to authenticated
  using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
create policy "delete own" on public.todos
  for delete to authenticated using ((select auth.uid()) = user_id);

A possède deux lignes, B en possède une. Ce qui est revenu :

Requête de Action Statut Corps
Invité tout lire 401 permission denied (pas de grant pour anon)
A tout lire 200 les 2 lignes de A
A insérer une ligne appartenant à B 403 new row violates row-level security policy
A modifier la ligne de B 200 []
A supprimer la ligne de B 200 []
A changer le propriétaire de sa ligne en B 403 new row violates row-level security policy

Les deux lignes 200 [] sont à retenir. La RLS ne rejette pas la modification ou la suppression de lignes que vous ne voyez pas ; elle les filtre, et zéro ligne modifiée est un succès. Si votre application prend « pas d’erreur » pour « enregistré », elle dira à l’utilisateur que sa modification a fonctionné alors que rien ne s’est passé. Demandez les lignes modifiées (.select() dans supabase-js, Prefer: return=representation en HTTP) et vérifiez qu’une est revenue.

Pourquoi une politique de mise à jour a-t-elle besoin de WITH CHECK ?

using choisit les lignes existantes qu’un utilisateur peut toucher ; with check décide de ce à quoi ces lignes peuvent ressembler ensuite. J’ai comparé trois versions de la même politique de mise à jour :

Politique de mise à jour A donne sa ligne à B
using (uid = user_id) with check (uid = user_id) 403, refusé
using (uid = user_id), sans with check refusé (Postgres réutilise using comme vérification)
using (uid = user_id) with check (true) 200, la ligne appartient maintenant à B

Omettre with check est plus sûr qu’il n’y paraît, car Postgres se rabat sur l’expression using. with check (true), que certains générateurs écrivent pour « autoriser les mises à jour », est ce qui permet à un utilisateur de déplacer une ligne vers le compte de quelqu’un d’autre. Écrivez la vérification en entier.

Comment rendre les profils publics mais modifiables seulement par leur propriétaire ?

create policy "public read" on public.profiles
  for select to anon, authenticated using (true);
create policy "own edit" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id) with check ((select auth.uid()) = id);

Sur l’exécution avec Postgres simple : l’invité a lu les deux profils, la mise à jour de l’invité a modifié 0 ligne, et update profiles set display_name = 'x' de A sans filtre a modifié exactement 1 ligne, celle de A. using (true) convient pour un select sur des données destinées à être publiques. Sur toute autre table, cela revient à ne pas avoir de RLS, et une politique écrite sans clause to s’applique aussi aux invités : mon invité a lu la ligne « privée » à travers une politique using (true) sans to.

Comment partager des lignes au sein d’une équipe ?

Le schéma habituel : une table memberships et une politique sur les données qui y cherche les équipes d’un utilisateur.

create policy "members read" on public.projects
  for select to authenticated
  using (org_id in (select org_id from public.memberships
                    where user_id = (select auth.uid())));

Cette politique est correcte. La fuite était à côté. J’ai donné à memberships la politique que suggèrent beaucoup de tutoriels, « les utilisateurs peuvent insérer leur propre adhésion » :

create policy "join any org" on public.memberships
  for insert to authenticated with check (user_id = (select auth.uid()));

Elle vérifie pour qui est la ligne, pas pour quelle équipe. A, membre de l’entreprise 1, a envoyé une requête :

Une politique d’insertion sur memberships. Utilisateur A de l’entreprise 1, via l’API REST. Politique : user_id = auth.uid(): Utilisateur A → rejoint la 2 → 201 Created → lit B payroll; Aucune politique d’insert: Utilisateur A → rejoint la 2 → refusé → ses projets seuls.
Figure 1. Mon test sur une pile Supabase locale (CLI 2.109.1), 24 octobre 2026.
Requête de A Statut Corps
lire les projets 200 A roadmap
insérer l’adhésion {user_id: A, org_id: 2} 201 créée
relire les projets 200 A roadmap, B payroll

La correction consiste à ne donner aucune politique d’insertion à memberships, pour que le navigateur ne puisse pas y écrire (sur l’exécution Postgres, la même insertion a alors été refusée avec 42501), et à ajouter les membres par un parcours d’invitation exécuté côté serveur ou dans une fonction qui vérifie l’invitation. Un utilisateur qui appartient à deux entreprises est le piège suivant ; je l’ai décrit dans RLS multi-tenant Supabase : fuite entre deux organisations.

Et si une politique doit lire sa propre table ?

« Les membres voient leurs coéquipiers » suppose une politique sur memberships qui lit memberships. Postgres refuse au moment de la requête :

ERROR 42P17: infinite recursion detected in policy for relation "members2"

Déplacez la recherche dans une fonction qui s’exécute avec les droits de son propriétaire, dans un schéma que l’API n’expose pas, avec un search_path fixé :

create schema private;
create function private.user_org_ids() returns setof int
  language sql stable security definer set search_path = ''
  as $$ select org_id from public.members2 where user_id = (select auth.uid()) $$;
revoke all on function private.user_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;

create policy "see teammates" on public.members2
  for select to authenticated using (org_id in (select private.user_org_ids()));

Ensuite, A voyait les deux membres de l’entreprise 1 et B ne voyait que lui-même. Gardez la fonction hors de public : tout ce qui s’y trouve est appelable via l’API.

Comment exiger la MFA pour des lignes sensibles ?

Ajoutez une politique restrictive par-dessus la normale. Les politiques restrictives sont combinées avec and, les deux doivent donc passer :

create policy "mfa only" on public.payouts as restrictive
  for select to authenticated using ((select auth.jwt() ->> 'aal') = 'aal2');

Avec le même utilisateur, une session en aal1 a lu 0 paiement et une session en aal2 en a lu 1.

Quelles tables doivent avoir la RLS activée et aucune politique ?

Les tables que seul votre serveur touche. La RLS activée sans politique refuse tout aux rôles du navigateur : sur l’exécution Postgres, l’invité et A ont lu 0 ligne, et l’insertion de A a été refusée avec 42501. Laisser la RLS désactivée est l’inverse. Une table avec la RLS désactivée et les grants par défaut, lue avec la clé publishable sans connexion :

Requête de l’invité Statut Corps
GET /leads 200 les deux lignes
DELETE /leads?id=gt.0 200 les deux lignes, supprimées

C’est de cela que parle l’avertissement de l’Advisor « RLS disabled in public ».

Une seule politique peut-elle couvrir plusieurs commandes ?

Pas avec une liste. for update, delete est une erreur de syntaxe (42601: syntax error at or near ","), même si on le trouve encore dans des recueils d’extraits publiés. for all est valide et applique un seul using et un seul with check à toutes les commandes, ce qui est rarement ce que l’on veut. Une politique d’insertion avec using échoue aussi : only WITH CHECK expression allowed for INSERT.

Les politiques ralentissent-elles les requêtes ?

Elles le peuvent. Une expression de politique s’exécute pour chaque ligne que Postgres examine. J’ai mesuré select * from big en tant qu’utilisateur A sur 200 000 lignes appartenant à 1 000 utilisateurs, A en possédant 200 (médiane de 7 exécutions, Postgres 17.10) :

Une politique propriétaire, quatre écritures. Millisecondes pour lire 200 lignes à soi sur 200 000. Médiane de 7 exécutions. auth.uid() = user_id, sans index: 156,30; (select auth.uid()), sans index: 7,40; auth.uid() = user_id, avec index: 0,21; (select auth.uid()), avec index: 0,05.
Figure 2. Mon test sur un Postgres 17.10 local avec les rôles Supabase recréés, 24 octobre 2026.
Politique et index Temps médian
auth.uid() = user_id, sans index 156,3 ms
(select auth.uid()) = user_id, sans index 7,4 ms
auth.uid() = user_id, index sur user_id 0,21 ms
(select auth.uid()) = user_id, index 0,05 ms

Envelopper l’appel dans select permet à Postgres de le calculer une fois par requête au lieu d’une fois par ligne ; l’index fait le reste. Le guide de performance RLS de Supabase rapporte le même ordre de grandeur sur sa propre table de 100 000 lignes (179 ms à 9 ms). Beaucoup d’articles de blog citent ces chiffres comme les leurs ; ceux ci-dessus viennent de mon exécution.

D’où doivent venir les rôles ?

De app_metadata ou de votre propre table, jamais de user_metadata. Un utilisateur connecté peut modifier son propre user_metadata avec supabase.auth.updateUser(), donc une politique qui vérifie auth.jwt() -> 'user_metadata' ->> 'role' lui permet de s’attribuer le rôle. Un trigger d’inscription qui copie un rôle depuis les métadonnées d’inscription fait de même ; ce cas est dans Supabase raw_user_meta_data : une inscription devient Owner.

Comment vérifier vos propres politiques ?

  • L’audit de sécurité Supabase gratuit lit votre projet en lecture seule et signale les politiques toujours vraies, les politiques d’écriture pour les invités, les politiques qui font confiance aux métadonnées utilisateur et les tables sans RLS, avec le SQL de correction pour chacune.
  • Aucun scanner ne sait quelles lignes appartiennent à qui. Pour cela, exécutez des requêtes en tant que deux utilisateurs comme dans cet article ; la méthode complète pour une application avec des rôles est dans comment tester Supabase RLS.
  • Ce que Supabase sécurise pour vous et ce qu’il vous laisse est dans Supabase est-il sûr ?.
  • Les mêmes tests de propriétaire et d’équipe, exécutés aussi sur Appwrite, sont dans Appwrite vs Supabase : les mêmes attaques sur les deux.

Limites. Une pile locale et un ordinateur portable, des données inventées, 24 octobre 2026. Les temps dépendent du matériel et de la forme des tables ; les rapports comptent plus que les millisecondes. Les politiques Storage et Realtime ne sont pas couvertes ici.

Pas sûr que vos politiques tiennent ?

Je regarde volontiers un schéma avec vous. Sans démarchage.

Me contacter

© 2026 Denys Zaichenko. Article original : https://madsalt.tech/fr/blog/supabase-rls-policy-examples/