Développement Web

Schéma Postgres et Row Level Security Supabase : tutoriel pratique 2026

12 دقائق للقراءة

📍 Article principal : Supabase 2026 : guide pratique.

Le RLS (Row Level Security) Postgres est la fondation sécurité de Supabase. Sans RLS, votre app est compromise. Ce tutoriel détaille les patterns validés pour SaaS B2B, marketplace, et e-commerce.

Prérequis

  • Supabase en production.
  • Compréhension SQL.
  • Niveau : intermédiaire/avancé.
  • Temps : 1-2h.

Étape 1 — Schema users (Supabase Auth)

Supabase crée auto auth.users. Étendre avec table profiles :

CREATE TABLE public.profiles (
  id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  username text UNIQUE,
  full_name text,
  avatar_url text,
  organization_id uuid REFERENCES organizations(id),
  role text NOT NULL DEFAULT 'member',
  created_at timestamptz DEFAULT now()
);

Étape 2 — Trigger auto-création profile

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
  INSERT INTO public.profiles (id, username, full_name)
  VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data->>'full_name');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

Étape 3 — Schema multi-tenant

CREATE TABLE organizations (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  slug text UNIQUE NOT NULL,
  plan text DEFAULT 'free',
  created_at timestamptz DEFAULT now()
);

CREATE TABLE products (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
  name text NOT NULL,
  price integer NOT NULL,
  currency text DEFAULT 'XOF',
  created_at timestamptz DEFAULT now()
);

CREATE INDEX idx_products_org ON products(organization_id);

Étape 4 — RLS policies multi-tenant

ALTER TABLE products ENABLE ROW LEVEL SECURITY;

-- Lecture : utilisateur voit produits de son org
CREATE POLICY "users see own org products"
ON products FOR SELECT
USING (
  organization_id IN (
    SELECT organization_id FROM profiles WHERE id = auth.uid()
  )
);

-- Création : admins org peuvent créer
CREATE POLICY "admins create products"
ON products FOR INSERT
WITH CHECK (
  organization_id IN (
    SELECT organization_id FROM profiles 
    WHERE id = auth.uid() AND role IN ('admin', 'editor')
  )
);

-- Update : éditeurs org modifient
CREATE POLICY "editors update products"
ON products FOR UPDATE
USING (
  organization_id IN (
    SELECT organization_id FROM profiles 
    WHERE id = auth.uid() AND role IN ('admin', 'editor')
  )
);

-- Delete : admins seulement
CREATE POLICY "admins delete products"
ON products FOR DELETE
USING (
  organization_id IN (
    SELECT organization_id FROM profiles 
    WHERE id = auth.uid() AND role = 'admin'
  )
);

Étape 5 — Public read avec filter

CREATE TABLE public_listings (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  status text DEFAULT 'draft',
  title text NOT NULL,
  description text
);

ALTER TABLE public_listings ENABLE ROW LEVEL SECURITY;

CREATE POLICY "public reads published"
ON public_listings FOR SELECT
USING (status = 'published');

Étape 6 — Helper functions

CREATE OR REPLACE FUNCTION current_user_role()
RETURNS text AS $$
  SELECT role FROM profiles WHERE id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER;

CREATE OR REPLACE FUNCTION current_user_org()
RETURNS uuid AS $$
  SELECT organization_id FROM profiles WHERE id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER;

-- Usage simplifié
CREATE POLICY "simpler check"
ON products FOR SELECT
USING (organization_id = current_user_org());

Étape 7 — Indexes performance

CREATE INDEX idx_profiles_org ON profiles(organization_id);
CREATE INDEX idx_products_org_status ON products(organization_id, created_at DESC);
CREATE INDEX idx_products_search ON products USING gin(to_tsvector('french', name || ' ' || description));

Étape 8 — Test policies

-- En tant qu'utilisateur X
SET ROLE authenticated;
SET request.jwt.claim.sub = 'user-id-X';
SELECT * FROM products;
-- Doit retourner uniquement produits org de X

Étape 9 — Migrations versionnées

Supabase CLI : supabase migration new add_products_table → fichier .sql versionné. Apply : supabase db push.

Étape 10 — Audit RLS

Studio → Authentication → Policies. Liste toutes les policies. Vérifier coverage de chaque table.

Erreurs fréquentes

Erreur Cause Solution
Frontend voit rien RLS sans policy SELECT Toujours créer SELECT policy
Service role bypass non voulu Frontend utilise service_role Frontend = anon ou authenticated, jamais service_role
Performance N+1 RLS Policies subquery lentes Index sur colonnes RLS, helper functions
Trigger profile fail SECURITY DEFINER manque Trigger avec SECURITY DEFINER
Policy contradictoire Plusieurs policies UNION Policy permissive vs restrictive
auth.uid() null Pas connecté RLS exige authenticated, sinon retourne vide

Particularités à intégrer en PME ouest-africaine

Trois précisions. Multi-currency : champ currency par produit (XOF, MAD, EUR). RLS pas affecté. Multi-pays : organizations.country pour filter par marché. Conformité : RLS = sécurité au niveau DB, satisfait audit ARTCI/CDP.

Tutoriels frères

FAQ

RLS performance impact ? Minimal avec indexes. Vérifier EXPLAIN.

Bypass RLS pour admin tasks ? service_role key (jamais frontend).

Test policies ? Studio → SQL Editor avec SET ROLE authenticated.

Migrations en prod ? Supabase CLI ou flyway. Versionnées Git.

pgvector pour IA ? Oui inclus, ALTER TABLE pour ajouter colonne vector.

Sur un angle proche

Démarrer en production sur un VPS

Pour passer du local à un serveur réellement accessible en ligne, Hostinger propose des plans VPS abordables avec sauvegarde automatique.

Voir les VPS →

Lien d affiliation. Si vous achetez via ce lien, le blog reçoit une petite commission sans surcoût pour vous.

Étape 1 — Préparer le projet Supabase et l’environnement local

Avant d’écrire la moindre policy RLS, il faut un projet Supabase prêt et un client SQL local pour itérer vite. Une équipe basée à Dakar ou Abidjan qui paie son plan Pro à 25 USD par mois (environ 16 400 FCFA au taux fixe 1 EUR = 655,957 FCFA, soit environ 23 EUR converti via USD) gagne énormément à exécuter ses migrations depuis sa machine plutôt que dans le SQL Editor du dashboard.

Créez le projet, récupérez l’URL Postgres dans Project Settings → Database → Connection string (mode session), puis installez la CLI officielle.

npm install -g supabase
supabase login
supabase link --project-ref votre-ref
supabase db pull

La commande db pull rapatrie le schéma actuel sous forme de migration. Vous devez voir un dossier supabase/migrations/ apparaître avec un fichier horodaté. C’est le signal que la connexion fonctionne et que vous avez bien la main sur le schéma versionné.

Étape 2 — Modéliser le schéma multi-tenant proprement

Un SaaS qui sert à la fois une fintech à Abidjan et un cabinet médical à Yaoundé doit isoler les données dès le schéma. La règle simple : chaque ligne métier porte une colonne org_id (ou tenant_id) qui référence l’organisation propriétaire. Cette colonne sera la clef de toutes les policies RLS.

create table organizations (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  created_at timestamptz default now()
);

create table memberships (
  user_id uuid references auth.users(id) on delete cascade,
  org_id uuid references organizations(id) on delete cascade,
  role text not null check (role in ('owner','admin','member')),
  primary key (user_id, org_id)
);

create table invoices (
  id uuid primary key default gen_random_uuid(),
  org_id uuid references organizations(id) on delete cascade not null,
  amount_xof bigint not null,
  status text not null default 'draft',
  created_at timestamptz default now()
);

Notez le amount_xof bigint : pour des montants en francs CFA, on évite les flottants. Une facture Mixx by Yas ou Wave de 1 250 000 FCFA reste un entier exact, jamais une virgule flottante. Après exécution dans le SQL Editor ou via supabase db push, vous devez voir vos trois tables dans le Table Editor avec leurs relations. Si une foreign key échoue, c’est presque toujours un ordre de création inversé.

Étape 3 — Activer Row Level Security sur chaque table métier

Par défaut Postgres autorise tout pour le rôle propriétaire. Tant que RLS n’est pas activé, le client JavaScript anonyme via la clef anon ne peut rien faire. Mais surtout, dès qu’on activera la moindre fonctionnalité d’authentification, oublier RLS sur une table c’est la fuir intégralement à tout utilisateur connecté. La règle est donc : RLS activé partout, sans exception, même sur les tables qu’on croit publiques.

alter table organizations enable row level security;
alter table memberships enable row level security;
alter table invoices enable row level security;

Après cette commande, toute requête SELECT depuis le client renvoie zéro ligne tant qu’aucune policy explicite n’autorise la lecture. C’est le comportement attendu : RLS est deny by default. Si vous voyez encore des lignes côté client, c’est que vous utilisez la clef service_role, qui bypasse RLS — à n’utiliser QUE côté serveur.

Étape 4 — Écrire les policies de lecture basées sur memberships

La policy de base : un utilisateur ne voit que les données des organisations dont il est membre. On utilise auth.uid() qui renvoie l’UUID de l’utilisateur connecté.

create policy "members read invoices"
on invoices for select
to authenticated
using (
  exists (
    select 1 from memberships m
    where m.org_id = invoices.org_id
      and m.user_id = auth.uid()
  )
);

create policy "members read their orgs"
on organizations for select
to authenticated
using (
  exists (
    select 1 from memberships m
    where m.org_id = organizations.id
      and m.user_id = auth.uid()
  )
);

Pour tester, créez deux utilisateurs via le dashboard Auth, ajoutez chacun à une organisation différente, puis depuis le SQL Editor lancez set local role authenticated; set local request.jwt.claims = '{"sub":"uuid-user-1"}'; avant un SELECT. Vous devez voir uniquement les factures de l’organisation du user 1. Si vous voyez les deux, votre policy a une faille — souvent un parenthésage manquant.

Étape 5 — Policies INSERT, UPDATE, DELETE avec WITH CHECK

La lecture seule ne suffit pas. Pour l’insertion il faut une clause with check qui valide que la ligne créée appartient bien à une organisation où l’utilisateur est membre. Sinon n’importe quel client connecté pourrait insérer une facture dans l’org du voisin.

create policy "members insert invoices"
on invoices for insert
to authenticated
with check (
  exists (
    select 1 from memberships m
    where m.org_id = invoices.org_id
      and m.user_id = auth.uid()
      and m.role in ('owner','admin','member')
  )
);

create policy "admins update invoices"
on invoices for update
to authenticated
using (
  exists (
    select 1 from memberships m
    where m.org_id = invoices.org_id
      and m.user_id = auth.uid()
      and m.role in ('owner','admin')
  )
)
with check (
  org_id = (select org_id from memberships where user_id = auth.uid() limit 1)
);

create policy "owners delete invoices"
on invoices for delete
to authenticated
using (
  exists (
    select 1 from memberships m
    where m.org_id = invoices.org_id
      and m.user_id = auth.uid()
      and m.role = 'owner'
  )
);

Le bon réflexe : tester chaque policy avec un compte de chaque rôle. Un membre simple doit pouvoir lire et créer mais pas update ni delete ; un admin peut update ; seul un owner peut delete. Si un membre simple arrive à supprimer, votre policy DELETE a un trou.

Étape 6 — Optimiser les performances RLS avec des fonctions SECURITY DEFINER

À chaque requête, Postgres réévalue la sous-requête EXISTS sur memberships. Sur une table à un million de lignes, ça devient lent. La technique standard : encapsuler la vérification d’appartenance dans une fonction security definer mise en cache par requête.

create or replace function public.is_member_of(target_org uuid)
returns boolean
language sql
security definer
stable
as $$
  select exists (
    select 1 from memberships
    where org_id = target_org
      and user_id = auth.uid()
  );
$$;

create policy "members read invoices v2"
on invoices for select
to authenticated
using (public.is_member_of(org_id));

Vérifiez le gain avec explain analyze select * from invoices; côté authenticated. Le plan doit montrer un Function Scan plutôt qu’un Subquery Scan répété. Sur un benchmark interne, on passe typiquement de 180 ms à 12 ms sur 100 000 lignes.

Étape 7 — Indexer les colonnes utilisées par RLS

Une policy sur org_id est inutilisable si la colonne n’a pas d’index. Postgres fera un seq scan complet à chaque requête, ce qui tue les perfs dès quelques milliers de lignes.

create index idx_invoices_org_id on invoices(org_id);
create index idx_memberships_user_org on memberships(user_id, org_id);
create index idx_memberships_org_user on memberships(org_id, user_id);

Le double index sur memberships (user→org et org→user) sert deux types de requêtes : « quelles orgs pour cet user » et « quels users dans cette org ». Après création, relancez explain analyze : vous devez voir Index Scan partout, jamais Seq Scan sur les tables métier.

Étape 8 — Déployer et automatiser via migrations versionnées

Tout ce qui précède doit vivre dans supabase/migrations/ et être commité dans Git. Jamais de modifications schéma directement dans le dashboard production : c’est ainsi qu’on perd la traçabilité et qu’on casse RLS sans s’en rendre compte.

supabase migration new add_rls_invoices
# editer le fichier généré avec les CREATE POLICY
supabase db push
supabase db diff --use-migra

La commande db diff compare le schéma local au schéma distant et génère automatiquement la migration manquante. Si elle renvoie « No schema differences detected », vos environnements sont synchronisés. En CI/CD, ajoutez une étape qui lance supabase db push --dry-run sur chaque pull request : zéro surprise au déploiement.

Sur le même thème sur l’authentification multi-rôles, consultez notre tutoriel Supabase Auth Magic Link et OTP 2026. Et pour exposer ces données à un front Next.js 15, voyez le guide Next.js 15 + Supabase Server Components.

مشاركة