Guide de conception de schéma multi-locataire Supabase RLS pour la production
Traduire en français
J'ai déployé trois applications SaaS multi-tenant sur Supabase au cours des deux dernières années. La première a été un désastre. Non pas parce que Supabase m'a laissé tomber -- ce n'était pas le cas -- mais parce que j'ai fondamentalement mal compris comment la sécurité au niveau des lignes (RLS) interagit avec la planification des requêtes à grande échelle. La deuxième était mieux. La troisième gère 2 000+ locataires avec des temps de requête inférieurs à 50 ms sur des tables contenant des millions de lignes.
Cet article contient tout ce que j'aurais souhaité qu'on me dise avant ce premier projet. Nous allons construire un vrai schéma multi-tenant à partir de zéro, configurer des politiques RLS qui fonctionnent réellement, et couvrir les cas limites qui ne surgissent que lorsque vous avez du vrai trafic qui frappe votre base de données.
Table des matières
- Approches de la multi-location dans Supabase
- La fondation : schéma des locataires et des utilisateurs
- Concevoir des politiques RLS qui ne tuent pas les performances
- Le motif tenant_id : bien le maîtriser
- Revendications JWT vs recherches en base de données
- Gérer les opérations inter-locataires
- Stratégie de migration et évolution du schéma
- Benchmarks de performance et optimisation
- Durcissement de la sécurité pour la production
- Exemple de schéma du monde réel
- FAQ

Approches de la multi-location dans Supabase
Avant d'écrire une seule ligne de SQL, soyons clairs sur les trois approches de la multi-location et pourquoi l'une d'elles gagne pour la plupart des projets Supabase.
| Approche | Niveau d'isolation | Complexité | Coût par locataire | Idéal pour |
|---|---|---|---|---|
| Base de données par locataire | Le plus élevé | Très élevée | 25 $/mois+ par locataire | Enterprise, conformité stricte |
| Schéma par locataire | Élevé | Élevée | 5 à 15 $/mois par locataire | SaaS de taille intermédiaire |
| Schéma partagé + RLS | Moyen | Moyen | Quelques centimes par locataire | La plupart des applications SaaS |
Base de données par locataire, c'est ce que vous utiliseriez si vous vendez à des banques ou des sociétés de santé qui exigent littéralement une infrastructure séparée. Supabase ne le rend pas facile -- vous géreriez plusieurs projets Supabase.
Schéma par locataire (utilisant les schémas PostgreSQL comme tenant_123.projects) semble séduisant mais devient un cauchemar de maintenance. Chaque migration s'exécute sur chaque schéma. J'ai essayé cela une fois. Avec 400 locataires, une simple migration ALTER TABLE a pris 45 minutes.
Le schéma partagé avec RLS est le compromis optimal pour 90 % des applications SaaS. Un ensemble de tables, un ensemble de migrations, et les politiques RLS de PostgreSQL gèrent l'isolation. C'est ce que nous construisons ici.
La fondation : schéma des locataires et des utilisateurs
Commençons par les tables essentielles. Je vais vous montrer le schéma que j'utilise en production, pas un jouet didactique.
-- Activer la génération UUID
create extension if not exists "uuid-ossp";
-- Locataires (organisations, espaces de travail, peu importe comment vous les appelez)
create table public.tenants (
id uuid primary key default uuid_generate_v4(),
name text not null,
slug text unique not null,
plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
settings jsonb not null default '{}',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Jonction d'adhésion : connecte auth.users à tenants
create table public.tenant_memberships (
id uuid primary key default uuid_generate_v4(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz not null default now(),
unique(tenant_id, user_id)
);
-- Critique : les index sur lesquels les politiques RLS s'appuieront
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);
Deux choses à remarquer. D'abord, j'utilise une table de jonction (tenant_memberships) au lieu de mettre un tenant_id directement sur le profil utilisateur. Les utilisateurs peuvent appartenir à plusieurs locataires -- c'est une exigence du monde réel pour presque chaque application SaaS. Deuxièmement, ces index ne sont pas optionnels. Sans eux, chaque vérification RLS effectue un balayage séquentiel sur la table des adhésions. J'ai vu cela ajouter 200 ms+ aux requêtes une fois que vous avez quelques milliers d'adhésions.
Concevoir des politiques RLS qui ne tuent pas les performances
C'est là que la plupart des didacticiels vous laissent tomber. Ils vous montrent une politique simple comme :
-- NE PAS FAIRE CELA EN PRODUCTION
create policy "Les utilisateurs peuvent voir les données de leur locataire"
on public.projects for select
using (
tenant_id in (
select tenant_id from public.tenant_memberships
where user_id = auth.uid()
)
);
Cela fonctionne. Cela passera vos tests. Et puis vous le déployez, obtenez 500 utilisateurs, et vous demandez pourquoi votre tableau de bord prend 4 secondes à charger.
Le problème est que PostgreSQL évalue cette sous-requête pour chaque ligne. Le planificateur de requêtes peut parfois l'optimiser, mais souvent ce n'est pas le cas -- surtout avec les JOINs impliqués.
Le motif de fonction Security Definer
Voici ce qui fonctionne réellement en production :
-- Créer une fonction qui retourne les ID de locataire de l'utilisateur
-- SECURITY DEFINER signifie qu'elle s'exécute avec les permissions du créateur de la fonction
-- C'est critique : cela contourne RLS sur la table des adhésions elle-même
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
select tenant_id
from public.tenant_memberships
where user_id = auth.uid();
$$;
-- Maintenant l'utiliser dans les politiques
create policy "tenant_isolation_select"
on public.projects for select
using (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_isolation_insert"
on public.projects for insert
with check (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_isolation_update"
on public.projects for update
using (tenant_id in (select public.get_user_tenant_ids()))
with check (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_isolation_delete"
on public.projects for delete
using (tenant_id in (select public.get_user_tenant_ids()));
Pourquoi est-ce plus rapide ? Le mot-clé STABLE indique à PostgreSQL que la fonction retourne le même résultat au sein d'une seule instruction. Le planificateur peut l'appeler une seule fois et réutiliser le résultat. Dans mes benchmarks, cela réduit le surcoût RLS de 60 à 80 % sur les requêtes qui touchent plusieurs lignes.
Le piège search_path
Voyez ce set search_path = public sur la fonction ? Ce n'est pas optionnel. Sans cela, un utilisateur malveillant pourrait potentiellement créer une fonction dans un autre schéma qui occulte auth.uid() et contourner votre sécurité. L'équipe Supabase a écrit à ce sujet, mais c'est facile à manquer.

Le motif tenant_id : bien le maîtriser
Chaque table qui contient des données spécifiques aux locataires doit avoir une colonne tenant_id. Pas d'exceptions. Même si la table a une clé étrangère à une autre table limitée au locataire. Voici pourquoi :
-- Votre table de projets
create table public.projects (
id uuid primary key default uuid_generate_v4(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
name text not null,
created_at timestamptz not null default now()
);
-- Les tâches appartiennent à des projets. Vous pourriez penser que tenant_id est redondant ici.
create table public.tasks (
id uuid primary key default uuid_generate_v4(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
status text not null default 'todo',
created_at timestamptz not null default now()
);
-- Index pour RLS + motifs de requêtes courants
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);
Oui, tenant_id sur tasks est dénormalisé. Oui, c'est le bon choix. Sans cela, la politique RLS sur tasks aurait besoin de JOINDRE à projects pour vérifier le locataire -- et ce JOIN se produit à chaque requête. Avec le tenant_id dénormalisé, la vérification RLS est une simple recherche d'index.
J'applique la cohérence avec un trigger :
create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
if NEW.tenant_id != (
select tenant_id from public.projects where id = NEW.project_id
) then
raise exception 'tenant_id mismatch: task tenant does not match project tenant';
end if;
return NEW;
end;
$$;
create trigger check_task_tenant
before insert or update on public.tasks
for each row execute function public.verify_tenant_consistency();
Revendications JWT vs recherches en base de données
Supabase vous permet d'intégrer des revendications personnalisées dans le token JWT. Certains les utilisent pour stocker l'ID du locataire actuel :
-- Lecture depuis JWT (rapide, mais avec des mises en garde)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'
Versus rechercher dans la base de données à chaque fois :
-- Recherche en base de données (plus lente, mais toujours actuelle)
select tenant_id from tenant_memberships where user_id = auth.uid()
| Aspect | Revendications JWT | Recherche en base de données |
|---|---|---|
| Vitesse | ~0,1 ms | ~1 à 5 ms |
| Actualité | Obsolète jusqu'à l'actualisation du token | Toujours actuelle |
| Commutation multi-locataire | Nécessite une actualisation du token | Immédiate |
| Sécurité lors de la révocation | Délai jusqu'à l'expiration du JWT | Immédiate |
| Complexité de mise en œuvre | Plus élevée (besoin d'Edge Function) | Plus basse |
Ma recommandation : utilisez les recherches en base de données avec le motif de fonction security definer pour la plupart des applications. La différence de performance est négligeable lorsque vous avez des index appropriés, et vous évitez une classe entière de bugs autour des tokens obsolètes.
Si vous servez 10 000+ utilisateurs simultanés et que l'économie de millisecondes compte, alors oui, déplacez l'ID du locataire actif dans le JWT. Vous aurez besoin d'une Edge Function Supabase (ou d'un hook) pour définir la revendication lorsque les utilisateurs changent de locataire, et vous devrez gérer le flux d'actualisation du token côté client.
Gérer les opérations inter-locataires
Certaines opérations franchissent légitimement les limites des locataires. Tableaux de bord administrateur, systèmes de facturation, agrégation d'analyses. Voici comment les gérer en toute sécurité.
Clé du rôle de service (à utiliser avec parcimonie)
La clé du rôle de service Supabase contourne complètement RLS. Utilisez-la uniquement dans le code côté serveur -- ne l'exposez jamais au client.
// Côté serveur uniquement (route API Next.js, Edge Function, etc.)
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // NE JAMAIS exposer cela
);
// Cela contourne RLS - à utiliser avec une extrême prudence
const { data } = await supabaseAdmin
.from('tenants')
.select('id, name, plan')
.eq('plan', 'enterprise');
Si vous construisez sur Next.js (nous faisons beaucoup de cela chez Social Animal), vos routes API et composants serveur sont le bon endroit pour les opérations du rôle de service.
Fonctions de base de données pour l'accès inter-locataire contrôlé
Pour un contrôle plus granulaire, créez des fonctions spécifiques :
create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
result json;
caller_role text;
begin
-- Vérifier que l'appelant est un administrateur du locataire cible
select role into caller_role
from tenant_memberships
where user_id = auth.uid() and tenant_id = target_tenant_id;
if caller_role not in ('owner', 'admin') then
raise exception 'Unauthorized: requires admin role';
end if;
select json_build_object(
'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
) into result;
return result;
end;
$$;
Stratégie de migration et évolution du schéma
Ajouter tenant_id aux tables existantes est la migration que tout le monde redoute. Voici l'approche qui minimise le temps d'arrêt :
-- Étape 1 : Ajouter une colonne nullable
alter table public.some_existing_table
add column tenant_id uuid references public.tenants(id);
-- Étape 2 : Remplissage (faire cela par lots pour les grandes tables)
update public.some_existing_table set tenant_id = (
select tenant_id from public.projects
where projects.id = some_existing_table.project_id
)
where tenant_id is null;
-- Étape 3 : Ajouter la contrainte NOT NULL
alter table public.some_existing_table
alter column tenant_id set not null;
-- Étape 4 : Ajouter un index
create index concurrently idx_some_table_tenant
on public.some_existing_table(tenant_id);
-- Étape 5 : Activer RLS et ajouter les politiques
alter table public.some_existing_table enable row level security;
create policy "tenant_isolation" on public.some_existing_table
for all using (tenant_id in (select public.get_user_tenant_ids()));
Le mot-clé concurrently sur la création de l'index est crucial -- sans lui, vous verrouillez la table pour tout le temps de la création d'index. Sur une table avec un million de lignes, cela pourrait être plusieurs minutes d'arrêt.
Benchmarks de performance et optimisation
J'ai exécuté des benchmarks sur un plan Supabase Pro (25 $/mois, au début de 2025) avec 500 000 lignes dans la table tasks réparties entre 1 000 locataires.
| Motif de requête | Sans RLS | RLS naïf | RLS optimisé | Surcoût |
|---|---|---|---|---|
| Sélectionner 50 lignes (locataire unique) | 2,1 ms | 18,4 ms | 3,8 ms | +81 % |
| Insérer une seule ligne | 1,2 ms | 4,1 ms | 1,9 ms | +58 % |
| Compter avec filtre | 3,4 ms | 22,1 ms | 5,2 ms | +53 % |
| Jointure sur 2 tables | 4,8 ms | 45,2 ms | 8,1 ms | +69 % |
"RLS optimisé" signifie : fonctions security definer, index composites appropriés, tenant_id dénormalisé sur les tables enfants, et volatilité de la fonction STABLE.
L'approche naïve (sous-requêtes en ligne, index manquants) rend RLS lent. Optimisée, la surcharge est tout à fait acceptable pour les charges de production.
Conseils d'optimisation supplémentaires
- Utilisez les index composites avec
tenant_idcomme colonne principale :create index on tasks(tenant_id, status, created_at) - Partitionnez les grandes tables par
tenant_idsi un seul locataire a des millions de lignes - Utilisez
pg_stat_statementspour trouver les requêtes lentes -- Supabase l'expose dans le tableau de bord sous Database > Query Performance - Considérez les vues matérialisées pour les analyses inter-locataires au lieu d'exécuter des agrégations coûteuses en temps réel
Durcissement de la sécurité pour la production
RLS est votre principale défense, mais ce ne devrait pas être la seule.
1. Refus par défaut
Activez toujours RLS sans politique par défaut -- cela signifie que si vous oubliez d'ajouter une politique à une nouvelle table, elle est verrouillée par défaut plutôt que grand ouverte.
alter table public.new_table enable row level security;
-- N'ajoutez pas une politique permissive jusqu'à ce que vous ayez réfléchi aux motifs d'accès
2. Journalisation d'audit
create table public.audit_log (
id bigint generated always as identity primary key,
tenant_id uuid not null,
user_id uuid,
action text not null,
table_name text not null,
record_id uuid,
old_data jsonb,
new_data jsonb,
created_at timestamptz not null default now()
);
-- Trigger d'audit générique
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if TG_OP = 'DELETE' then
insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
return OLD;
else
insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
values (
NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id,
to_jsonb(NEW),
case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
);
return NEW;
end if;
end;
$$;
3. Limitation de la fréquence à la périphérie
RLS empêche la fuite de données, mais n'empêche pas les abus. Utilisez les Edge Functions Supabase ou votre middleware Next.js pour la limitation de la fréquence. Si vous construisez avec Astro, vous pouvez gérer cela dans vos points de terminaison serveur.
4. Testez vos politiques
Écrivez des tests réels. Utilisez l'environnement de développement local de Supabase (supabase start) et testez que :
- L'utilisateur A ne peut pas voir les données du locataire de l'utilisateur B
- La suppression d'une adhésion révoque immédiatement l'accès
- Le rôle de service contourne correctement RLS
- Les politiques d'insertion/mise à jour empêchent la falsification de tenant_id
// Exemple de test avec Vitest
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';
describe('RLS Policies', () => {
it('should prevent cross-tenant data access', async () => {
const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: `Bearer ${tokenA}` } }
});
const { data, error } = await userA
.from('projects')
.select('*')
.eq('tenant_id', TENANT_B_ID);
expect(data).toHaveLength(0); // RLS should filter these out
});
});
Exemple de schéma du monde réel
Voici un schéma condensé mais complet pour une SaaS de gestion de projets. C'est proche de ce que j'ai déployé en production.
-- Activer RLS sur toutes les tables
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;
-- Locataires : les utilisateurs peuvent voir les locataires auxquels ils appartiennent
create policy "view_own_tenants" on public.tenants for select
using (id in (select public.get_user_tenant_ids()));
-- Seuls les propriétaires peuvent mettre à jour les paramètres des locataires
create policy "owners_update_tenants" on public.tenants for update
using (
id in (
select tenant_id from public.tenant_memberships
where user_id = auth.uid() and role = 'owner'
)
);
-- Adhésions : les membres peuvent voir les autres membres de leurs locataires
create policy "view_tenant_members" on public.tenant_memberships for select
using (tenant_id in (select public.get_user_tenant_ids()));
-- Seuls les administrateurs+ peuvent gérer les adhésions
create policy "admins_manage_members" on public.tenant_memberships for insert
with check (
tenant_id in (
select tenant_id from public.tenant_memberships
where user_id = auth.uid() and role in ('owner', 'admin')
)
);
-- Projets et tâches : isolation de locataire standard
create policy "tenant_projects" on public.projects for all
using (tenant_id in (select public.get_user_tenant_ids()))
with check (tenant_id in (select public.get_user_tenant_ids()));
create policy "tenant_tasks" on public.tasks for all
using (tenant_id in (select public.get_user_tenant_ids()))
with check (tenant_id in (select public.get_user_tenant_ids()));
Si vous construisez quelque chose comme ceci et que vous voulez une équipe qui l'a déjà fait auparavant, consultez notre travail de développement de CMS sans tête -- nous avons associé les backends Supabase à des frontends sans tête pour plusieurs clients.
FAQ
RLS Supabase ajoute-t-il une latence significative aux requêtes ?
Avec des politiques optimisées (fonctions security definer, index appropriés, tenant_id dénormalisé), le surcoût est généralement de 50 à 80 % en haut du temps de requête brut. Pour une requête qui prend 3 ms sans RLS, attendez-vous à 5-6 ms avec elle. C'est tout à fait acceptable pour une utilisation en production. L'approche naïve peut ajouter 10 à 20 fois le surcoût, c'est pourquoi l'optimisation est si importante.
Puis-je utiliser RLS avec les abonnements Supabase Realtime ?
Oui. Supabase Realtime respecte les politiques RLS. Lorsqu'un client s'abonne à des modifications sur une table, il ne recevra que des événements pour les lignes qu'il est autorisé à voir. Cela fonctionne automatiquement -- vous n'avez pas besoin d'ajouter de logique de filtrage supplémentaire sur le client. Assurez-vous juste que vos politiques RLS sont efficaces, car elles sont évaluées pour chaque diffusion.
Comment gère-t-on la commutation de locataire dans l'interface utilisateur ?
Stockez l'ID du locataire actif dans l'état de votre application (contexte React, magasin Zustand, etc.) et transmettez-le comme filtre dans vos requêtes. Puisque RLS limite déjà les résultats aux locataires auxquels l'utilisateur appartient, la commutation n'est qu'une question de changer le tenant_id par lequel vous filtrez. Pas besoin d'actualiser le token si vous utilisez l'approche de recherche en base de données.
Dois-je utiliser l'authentification intégrée de Supabase ou un fournisseur externe comme Clerk ?
Supabase Auth s'intègre naturellement à RLS via auth.uid(). Si vous utilisez un fournisseur externe, vous devrez synchroniser les utilisateurs dans Supabase et utiliser des revendications JWT personnalisées ou une table de mappage. Je m'en tiendrais à Supabase Auth sauf si j'avais une raison spécifique de ne pas le faire -- cela économise un travail d'intégration important.
Combien de locataires un seul projet Supabase peut-il gérer ?
Avec la multi-location de schéma partagé, j'ai personnellement exécuté 2 000+ locataires sur un plan Supabase Pro (25 $/mois). La limite pratique dépend du volume de données total et des motifs de requête, pas du nombre de locataires. Une instance Supabase Pro avec 2 vCPU et 1 GB de RAM peut gérer des tables avec des dizaines de millions de lignes si vos index sont corrects.
Que se passe-t-il si j'oublie d'activer RLS sur une nouvelle table ?
Si vous utilisez le client Supabase avec la clé anon, toute table sans RLS activée est accessible à quiconque possède cette clé. C'est le plus grand écueil de Supabase. Mettez en place une vérification CI qui vérifie que toutes les tables du schéma public ont RLS activé. Vous pouvez interroger pg_tables joints avec pg_class pour automatiser cette vérification.
Puis-je utiliser RLS avec les Edge Functions Supabase ?
Oui. Les Edge Functions peuvent créer un client Supabase en utilisant soit la clé anon (RLS s'applique) soit la clé du rôle de service (RLS contourné). Utilisez la clé anon avec le JWT de l'utilisateur pour les opérations orientées utilisateur, et la clé du rôle de service pour les tâches administratives qui nécessitent un accès inter-locataire.
Comment je migre d'une application monolocataire à multi-tenant ?
Ajoutez tenant_id comme colonne nullable, remplissez-la pour toutes les données existantes (toutes les lignes obtiennent le même ID de locataire puisqu'il était monolocataire), puis ajoutez la contrainte NOT NULL, les index et les politiques RLS. Testez extensivement avec vos données existantes avant d'activer RLS -- une politique incorrecte peut verrouiller tous vos utilisateurs. Utilisez l'environnement de développement local de Supabase pour répéter la migration.