← retour Ancien moniteur de terminal rougeoyant sur un bureau en bois sombre avec des papiers éparpillés et une lumière grise douce

Supabase RLS : les politiques que j'écris sur chaque projet

En 2021, un client SaaS m'a contacté environ six semaines après le lancement. Son produit était un outil de gestion de projets. Interface agréable, onboarding solide, rétention correcte. Puis l'un de leurs utilisateurs bêta a remarqué quelque chose : en modifiant le project_id dans une requête GET, ils pouvaient lire les données d'un autre utilisateur. Pas de contournement d'authentification. Pas d'injection SQL. Juste une politique manquante. La table avait RLS activé, mais la politique SELECT était grande ouverte, USING (true). Quelqu'un l'avait recopié d'un tutoriel sans jamais y revenir.

Cet incident m'a marqué. Depuis, je traite RLS comme une architecture, pas comme une réflexion tardive. Et après avoir construit plus de 12 000 sites et applications chez Seahawk, j'ai une poignée de politiques que j'écris presque par réflexe, avant même la première ligne de code frontend.

Voici cette liste.

Pourquoi RLS vaut les frictions qu'il engendre

Supabase repose sur PostgreSQL, ce qui signifie que Row Level Security est une fonctionnalité de base de données de première classe, pas un ajout. Quand vous activez RLS sur une table et qu'un utilisateur l'interroge via le client Supabase, chaque ligne est filtrée automatiquement par vos politiques. Peu importe ce que votre couche API fait ou ne fait pas.

C'est là le point clé. J'ai travaillé avec des équipes utilisant REST, GraphQL, edge functions et background jobs frappant tous la même base de données. Imposer le contrôle d'accès au niveau application à travers tout ça, c'est un cauchemar de coordination. L'imposer au niveau base de données, c'est juste... fait.

Honnêtement, la friction d'écrire les politiques se paie d'elle-même la première fois qu'un junior oublie d'ajouter une clause WHERE.

Il y a une chose que les gens mal comprennent. Activer RLS sans aucune politique ne signifie pas « accès ouvert ». Cela signifie aucun accès du tout pour les rôles normaux. Chaque ligne est refusée par défaut. Donc si vous activez RLS et que votre app casse immédiatement, voilà pourquoi.

Les quatre tables que je protège toujours en priorité avec RLS

Tout n'a pas besoin du même niveau de contrôle. Mais ces quatre types de tables sont verrouillés avant que je ne touche à quoi que ce soit d'autre.

  • Tables de profil utilisateur (profiles, users, accounts) : l'évident. Les utilisateurs doivent lire leur propre ligne. Peut-être que les admins peuvent lire tous les profils. Personne ne devrait écrire sur le profil de quelqu'un d'autre.
  • Tables de ressource/contenu (projects, documents, posts) : peu importe le « truc » principal dans votre app. La propriété est généralement simple ici.
  • Tables de facturation et d'abonnement : si vous utilisez Stripe et stockez les données de plan, l'état d'abonnement, ou l'historique des factures dans Supabase, c'est à verrouiller bien fort. J'ai vu des apps exposer accidentellement les dates de fin d'essai aux autres utilisateurs.
  • Journaux d'audit : ce sont des lectures seules pour les utilisateurs (le cas échéant). Seul le rôle service devrait les écrire.

Les politiques que j'écris réellement

1. La politique réservée au propriétaire (mon motif le plus utilisé)

C'est celle que j'écris plus que toute autre. Prémisse simple : les utilisateurs ne voient que les lignes qu'ils possèdent.

`` create policy "Users can view own rows" on profiles for select using (auth.uid() = user_id); ``

auth.uid() est un helper Supabase qui retourne l'UUID de l'utilisateur actuellement authentifié. Propre, rapide, indexé si user_id est indexé. J'associe ça avec une politique insert qui définit user_id à auth.uid() par défaut, pour que les utilisateurs ne puissent pas insérer des lignes en se faisant passer pour quelqu'un d'autre.

`` create policy "Users can insert own rows" on profiles for insert with check (auth.uid() = user_id); ``

La clause with check concerne les opérations d'écriture. using concerne les lectures. Beaucoup de gens confondent ces deux et se retrouvent avec des stratégies qui semblent correctes mais n'empêchent pas réellement les mauvaises insertions.

2. La stratégie Org/Team (Applications Multi-Tenant)

C'est là que ça devient intéressant. Pour tout SaaS multi-tenant, j'ai besoin que les utilisateurs voient les lignes appartenant à leur organisation, pas seulement à eux-mêmes.

Le pattern sur lequel j'aboutis : une table de liaison memberships qui relie les utilisateurs aux organisations.

`` create policy "Org members can view org resources" on projects for select using ( exists ( select 1 from memberships where memberships.org_id = projects.org_id and memberships.user_id = auth.uid() ) ); ``

Seahawk avait un projet fintech où l'org avait des dizaines d'utilisateurs, certains avec des rôles en lecture seule, d'autres avec accès en écriture. Nous avons étendu ce pattern avec une colonne role sur memberships et l'avons utilisée directement dans la stratégie. Donc un rôle viewer ne pouvait pas exécuter UPDATE ou DELETE au niveau de la base de données, point final. Non appliqué par l'API. Appliqué par la base de données.

3. La stratégie Public Read / Owner Write

Pour le contenu visible publiquement mais modifiable uniquement par le propriétaire. Articles de blog, profils publics, listes de produits.

``` create policy "Anyone can read published posts" on posts for select using (published = true);

create policy "Authors can update own posts" on posts for update using (auth.uid() = author_id) with check (auth.uid() = author_id); ```

Deux stratégies distinctes. Je vois des gens essayer de les combiner en une seule et se retrouver avec une logique difficile à raisonner. Gardez-les séparées. Postgres les combinera avec OR pour la même opération automatiquement si nécessaire.

4. La Trappe d'Échappement Service Role

Certaines opérations ont légitimement besoin de contourner RLS. Tâches de fond, webhooks, scripts admin. Pour ceux-ci j'utilise la clé service_role, qui contourne entièrement RLS.

Mais voilà : je n'expose jamais la clé service role dans le code frontend. Jamais. Elle se trouve uniquement dans les variables d'environnement côté serveur. J'ai revu des bases de code où elle était en dur dans un répertoire pages/ de Next.js. C'est votre base de données entière, grande ouverte.

Si vous utilisez les fonctions edge de Supabase, vous pouvez utiliser le client service role à l'intérieur en toute sécurité, car les fonctions edge s'exécutent côté serveur. La propre documentation de Supabase sur l'auth et les service roles mérite d'être lue de bout en bout si vous ne l'avez pas déjà fait.

5. La Stratégie Admin Override

Pour les applications avec un panneau d'administration, j'ajoute une stratégie qui accorde aux admins un accès complet, vérifiée par rapport à un rôle stocké dans les métadonnées JWT de l'utilisateur ou une table user_roles séparée.

`` create policy "Admins can do everything" on projects for all using ( exists ( select 1 from user_roles where user_roles.user_id = auth.uid() and user_roles.role = 'admin' ) ); ``

J'avais l'habitude de stocker les rôles dans les custom claims JWT, ce qui est plus rapide (pas de sous-requête), mais cela signifie que vous devez ré-émettre le JWT à chaque fois qu'un rôle change. Pour la plupart des applications, la sous-requête va bien. Si vous voyez des problèmes de performance à grande échelle, les custom claims JWT via les hooks Supabase Auth est la solution.

Erreurs Courantes Que J'ai Faites (Et Vues)

Soyez direct avec les choses qui m'ont réellement causé des problèmes ou à mes clients.

  1. Oublier les stratégies UPDATE et DELETE. C'est facile d'écrire une stratégie SELECT et de penser que c'est fini. Ce n'est pas le cas. Testez les quatre opérations : SELECT, INSERT, UPDATE, DELETE. J'utilise maintenant le testeur de stratégies intégré du tableau de bord Supabase, mais pendant des années j'écrivais du SQL brut dans psql et testais manuellement.
  2. Confusion USING vs WITH CHECK. USING filtre les lignes qu'une requête peut voir. WITH CHECK valide si une opération d'écriture est autorisée. Pour UPDATE, vous avez besoin des deux : USING pour contrôler les lignes pouvant être ciblées, WITH CHECK pour contrôler l'apparence de la ligne après la mise à jour.
  3. Boucles de stratégies récursives. Si votre stratégie sur la table A interroge la table B, et que la table B a une stratégie qui interroge la table A, vous obtenez une récursion infinie. J'ai eu ça une fois avec une table teams et team_members qui se référençaient mutuellement. Le correctif : utiliser des fonctions security definer pour casser le cycle.
  4. Ne pas tester en tant qu'utilisateur anonyme. Supabase vous permet d'utiliser le rôle anonymous (anon). Testez toujours vos stratégies en tant qu'utilisateur authenticated et anon. J'utilise Postman avec différents tokens d'authentification pour simuler cela, en alternant entre aucun token, un token d'utilisateur valide et le token d'un utilisateur différent.
  5. Laisser RLS désactivé sur les buckets de stockage. RLS s'applique aussi à la table storage.objects. Si vous créez un bucket Supabase Storage et laissez cette table non protégée, n'importe qui peut lire vos fichiers « privés » s'il devine le chemin. J'ai appris cela à mes dépens sur un projet client qui stockait des documents téléchargés par les utilisateurs.

Comment je teste mes politiques avant de les déployer

C'est mon vrai processus, pas une checklist théorique.

  1. Écrire la politique dans l'éditeur SQL de Supabase.
  2. Ouvrir un deuxième onglet du navigateur, se connecter avec un autre utilisateur de test.
  3. Essayer d'accéder à des données qui doivent être bloquées. Confirmer que c'est bloqué.
  4. Essayer d'accéder à des données qui doivent être visibles. Confirmer que ça fonctionne.
  5. Exécuter un UPDATE et un DELETE sur une ligne que je ne possède pas. Devrait échouer.
  6. Vérifier les logs de Supabase pour les erreurs de violation de politique de sécurité au niveau des lignes.

Pour tout ce qui est compliqué, notamment les politiques multi-tenant pour les organisations, j'écris un petit script de test utilisant le client supabase-js avec deux sessions utilisateur différentes et je vérifie les résultats attendus. Ça prend environ 20 minutes à écrire, et ça évite des heures de débogage en production.

Quand NE PAS utiliser RLS

RLS n'est pas toujours le bon outil.

Si vous construisez un outil d'admin interne où tous les utilisateurs sont des employés de confiance, RLS ajoute de la complexité sans grand bénéfice. Une simple vérification d'authentification côté serveur suffit. Si votre modèle de données est si complexe que les politiques nécessitent des sous-requêtes imbriquées sur 5 niveaux, vous seriez peut-être mieux servis en appliquant le contrôle d'accès dans une couche API avec une décomposition appropriée des services.

Aussi : si vous utilisez Supabase uniquement comme backend avec votre propre API en face (sans jamais exposer l'URL Supabase ou la clé anon aux clients), RLS est optionnel. Votre API devient votre couche de sécurité. Cela dit, j'ajoute quand même des politiques basiques dans ces cas parce que la défense en profondeur en vaut la peine.

Écoutez, RLS est un outil. Pas une religion. Utilisez-le là où il rend votre système plus simple et sécurisé. Ne le cargo-cultisez pas parce qu'un tutoriel vous l'a dit.

FAQ

Ai-je besoin de RLS si j'utilise Supabase uniquement avec une API côté serveur ?

Pas strictement. Si votre frontend ne touche jamais Supabase directement et que tout passe par votre propre serveur, votre API est la couche de sécurité. Mais je recommande quand même d'ajouter au moins des politiques basées sur la propriété comme deuxième ligne de défense. Si quelqu'un trouve un bug dans votre API, RLS attrape ce qui s'échappe.

RLS affecte-t-il les performances ?

Ça peut, si vos politiques impliquent des sous-requêtes coûteuses sur de grandes tables. La solution est presque toujours l'indexation. Assurez-vous que les colonnes utilisées dans les conditions de vos politiques (user_id, org_id, etc.) ont des index. Sur un projet l'année dernière, ajouter un index sur org_id a réduit le temps d'évaluation des politiques de ~40ms à moins de 2ms sur une table avec 800k lignes.

Puis-je utiliser RLS avec Supabase Realtime ?

Oui. Les abonnements Realtime respectent les politiques RLS. Si un utilisateur s'abonne aux changements sur une table, il ne recevra les événements que pour les lignes que ses politiques lui permettent de voir. C'est l'une des bonnes décisions architecturales vraiment élégantes de Supabase.

Quelle est la différence entre `for all` et écrire des politiques séparées ?

for all crée une seule politique couvrant SELECT, INSERT, UPDATE et DELETE. C'est pratique pour les patterns de dépassement admin. Pour tout le reste, j'écris des politiques séparées par opération parce que les conditions sont généralement différentes. SELECT pourrait permettre les lectures publiques tandis qu'INSERT nécessite la propriété. Les politiques séparées sont plus faciles à comprendre quand quelque chose tourne mal à 23h.

Comment je débogue une politique qui bloque des requêtes qu'elle ne devrait pas ?

D'abord : vérifier que auth.uid() retourne réellement une valeur. Si l'utilisateur n'est pas authentifié, ça retourne null et la plupart des politiques échoueront. Deuxièmement : mettre temporairement la politique à USING (true) pour confirmer que la requête elle-même fonctionne. Troisièmement : ajouter une politique de test qui enregistre les valeurs que vous vérifiez (en utilisant une fonction security definer qui lève une notice). Les logs du tableau de bord Supabase affichent aussi les violations RLS, ce qui rend ça bien moins pénible qu'avant.

---

La sécurité au niveau des lignes n'est pas un travail glamour. Personne n'écrit d'articles de blog sur la violation qui n'a pas eu lieu. Mais cet incident en 2021 avec les données de projet exposées m'a appris que l'écart entre « RLS activé » et « RLS configuré correctement » est plus grand que la plupart des gens ne le pensent. Ces politiques comblent cet écart. Du moins, c'est le cas pour moi.

← retour