---
title: "Supabase RLS policy : exemples testés via l’API"
description: "Des exemples de Supabase RLS policy à copier : lignes personnelles, profils publics, équipes, MFA, testés en invité, en propriétaire et en autre utilisateur."
url: "https://madsalt.tech/fr/blog/supabase-rls-policy-examples/"
author: "Denys Zaichenko"
author_url: "https://madsalt.tech/fr/about/"
language: "fr"
published: 2026-10-27
---

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

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 :

```sql
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 :

```sql
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 ?

```sql
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.

```sql
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 » :

```sql
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.](https://madsalt.tech/charts/fr/supabase-rls-policy-examples-join.svg)

*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](https://madsalt.tech/fr/notes/supabase-rls-user-in-two-organizations/).

## 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 :

```text
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é :

```sql
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 :

```sql
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.](https://madsalt.tech/charts/fr/supabase-rls-policy-examples.svg)

*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](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv) 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](https://madsalt.tech/fr/notes/supabase-raw-user-meta-data-role/).

## Comment vérifier vos propres politiques ?

- L’[audit de sécurité Supabase](https://madsalt.tech/fr/tools/supabase-security-check/) 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](https://madsalt.tech/fr/blog/how-to-test-supabase-rls/).
- Ce que Supabase sécurise pour vous et ce qu’il vous laisse est dans [Supabase est-il sûr ?](https://madsalt.tech/fr/blog/is-supabase-secure/).
- 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](https://madsalt.tech/fr/blog/appwrite-vs-supabase/).

**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](https://madsalt.tech/fr/#contact)
