Fait partie du guide Lancer un SaaS : les décisions qu'on ne reprend pas sans payer
Le signup qui ne marchait qu'une fois par connexion, et ce que ça m'a appris sur l'archi d'un SaaS

Le 19 mai 2026, j’ai ouvert le dépôt d’asap.cool en Rust, avec une décision écrite ce jour-là : Deviso.pro a des utilisateurs payants, c’est le seul actif à protéger. Le bug de ce jour-là, c’était le signup. Il marchait une fois par connexion. L’inscription suivante, sur la même connexion rendue par le pool, prenait un 500 avec une erreur Postgres 22P02, une syntaxe invalide pour un uuid.
Je posais le contexte d’organisation avec set_config('app.org_id', …, true), le true voulant dire « pour cette transaction seulement ». En fin de transaction, le paramètre ne revient pas à NULL. Il revient à la chaîne vide. Et ''::uuid, Postgres refuse de le caster. Le premier inscrit sur une connexion neuve passait, le deuxième héritait d’un '' et tombait. Le correctif tient en une expression, NULLIF(current_setting('app.org_id', true), '')::uuid, et c’est devenu la règle du projet.
Le truc, c’est que ce bug minuscule dit tout de la suite. La RLS n’est pas un interrupteur qu’on active, c’est une chaîne de types et de rôles, et le maillon qui casse est rarement celui qu’on regarde. Tout ce qui suit, c’est ce que j’ai appris sur cette chaîne en montant asap.cool et en reprenant les SaaS de clients, et surtout quelles décisions d’architecture ne se défont pas. Si tu démarres plus en amont, le guide pour lancer un SaaS pose le décor, et notamment la question de l’identité.
Tout ce que je cite de documentation ici, je l’ai lu le 14 septembre 2026.
Ce qui se défait, et ce qui se paie
Quand tu montes un SaaS seul, tout le monde te dit que chaque choix est critique. Le framework, l’ORM, l’hébergeur, le découpage en services, la base. Alors le tri le plus utile que je connaisse n’est pas technique, il est économique, et il vient de la lettre aux actionnaires d’Amazon pour 2015 : il y a des décisions à sens unique, à prendre lentement, et des décisions réversibles, à prendre vite. Le risque que Bezos décrit, c’est de traiter les secondes comme les premières et de ne plus rien livrer.
Pour faire simple, sur un SaaS multi-tenant, moi je n’en vois que trois qui collent vraiment : le modèle de tenancy, le moteur de base de données, et qui possède l’identité de tes utilisateurs. Le moteur, je ne le discute pas ici : c’est Postgres sur les cinq projets dont je parle, et tout ce qui suit sur l’isolation en dépend. Tout le reste se remplace. Le framework front, l’hébergeur, le langage d’un service, le découpage en modules ou en services, tu les repasseras, et j’en parle plus bas avec ce que ça m’a coûté.
Sur la tenancy, ce n’est pas moi qui affirme que ça colle, c’est Microsoft dans sa doc Azure SQL : « Switching to a different model later is sometimes costly. » Le « parfois » compte. Ce que j’ai à mettre en face, c’est ce que ça a coûté sur mes projets.
Deviso : des organisations dans le schéma, aucune dans les requêtes
Deviso.pro, l’ancêtre d’asap.cool, avait un modèle Organization dès le départ. En avril et mai 2026, en relisant le code avant de le migrer, j’ai trouvé que les données métier n’étaient pas scopées dessus. Les modèles existaient, les requêtes ne les regardaient pas. Findings P0 : un IDOR sur le TOTP, des update et delete sans userId. Un utilisateur connecté pouvait toucher les lignes d’un autre en changeant un identifiant dans l’URL.
Ce n’est pas un choix de tenancy qui a été mal fait. C’est un choix qui n’a pas été fait, et qui s’est fait tout seul, par défaut, dans chaque requête écrite sans filtre. Le coût de le rattraper, je l’ai payé huit jours plus tard sur asap.cool : FORCE ROW LEVEL SECURITY dès le premier jour, avant la première fonctionnalité. Il y a 105 tables sous cette règle aujourd’hui.
La décision de tenancy que tu ne prends pas se prend quand même, une requête à la fois.
Du coup, ma règle depuis Deviso tient en une phrase : ne jamais assumer d’isolation multi-tenant. Si elle n’est pas dans la base, elle n’existe pas.
SimplyJury : la RLS activée qui ne protège personne
L’autre façon de se faire avoir, c’est d’avoir la RLS et de croire qu’elle tourne. Sur SimplyJury, un MVP repris d’un autre prestataire sur Supabase, la RLS était activée sur les tables. Le jour où je l’ai testée vraiment, j’ai trouvé qu’elle n’était jamais exécutée. Il y avait plusieurs causes empilées, et chacune suffisait :
app_is_admin()renvoyaitTRUEquand il n’y avait pas de contexte, donc pour tout appel anonyme ;- le rôle
anonavait des droits sur 35 tables ; - des policies mutuellement récursives servaient de rempart par accident ;
- une policy en
USING (true).
Résultat, quelques centaines de profils lisibles avec la clé publique, celle qui est dans le bundle JavaScript. Ça ne plantait nulle part, les pages fonctionnaient, et c’est exactement pour ça que personne ne l’avait vu. Le fix n’a pas été une policy de plus. J’ai écrit 28 attentes, chacune testée sous un rôle sans BYPASSRLS, du genre « un centre ne voit pas les demandes des autres centres », et j’ai fait passer la base au vert une attente à la fois.
En gros, une RLS activée sans test sous le bon rôle, c’est une RLS dont tu ne sais rien. Et le bon rôle, c’est le point suivant.
Le script qui prouve que ton rôle contourne la RLS
Le piège est écrit dans la doc PostgreSQL : le propriétaire d’une table contourne ses policies par défaut, et les superusers ou les rôles BYPASSRLS les contournent toujours. Si ton application se connecte avec le rôle qui a fait les migrations, tes policies sont décoratives. Plutôt que de me croire, tu peux le faire tourner :
-- Deux rôles : celui qui possède la table, celui qui l'interroge.
-- Aucun des deux n'est superuser, sinon le test ne prouve rien.
CREATE ROLE demo_owner LOGIN;
CREATE ROLE demo_app LOGIN;
GRANT CREATE, USAGE ON SCHEMA public TO demo_owner;
GRANT USAGE ON SCHEMA public TO demo_app;
SET ROLE demo_owner;
CREATE TABLE notes (id serial primary key, tenant_id uuid, body text);
INSERT INTO notes (tenant_id, body) VALUES
('11111111-1111-1111-1111-111111111111', 'client A'),
('22222222-2222-2222-2222-222222222222', 'client B');
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON notes
-- le `true` évite une exception quand le réglage est absent
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
GRANT SELECT ON notes TO demo_app;
SELECT count(*) FROM notes; -- propriétaire, RLS activée -> 2
ALTER TABLE notes FORCE ROW LEVEL SECURITY;
SELECT count(*) FROM notes; -- propriétaire, RLS forcée -> 0
SET ROLE demo_app;
SELECT count(*) FROM notes; -- rôle applicatif, sans SET -> 0
SET app.current_tenant = '11111111-1111-1111-1111-111111111111';
SELECT count(*) FROM notes; -- rôle applicatif, avec SET -> 1
Les quatre valeurs en commentaire sont celles que j’ai relevées sur PostgreSQL 18.6. La deuxième est celle qui compte : sans FORCE, le propriétaire lit tout, policy ou pas. Sur asap.cool, le rôle de l’application est créé en NOBYPASSRLS et il n’est propriétaire de rien. Ce n’est pas la discipline du développeur qui garantit l’isolation, c’est le rôle. Sur SimplyJury, c’est exactement la deuxième ligne que j’ai retrouvée en vrai : le webhook Stripe écrivait dans une table sous RLS, sans session, et ça marchait parce que le rôle de connexion était propriétaire. Le jour où ce rôle change, le webhook s’arrête d’écrire en répondant 200.
Et si tu repenses à mon signup du 19 mai, le cast dans la policy est un endroit dangereux : une policy qui lève une exception ne filtre pas, elle fait tomber la requête.
Le webhook qui lisait zéro ligne, et le pooler qui oublie ton SET
Le 27 mai, le jour où le billing d’asap.cool est passé en live, la RLS m’a piégé dans l’autre sens. Un webhook Stripe arrive sans utilisateur, donc sans contexte d’organisation. Sans policy SELECT pour ce mode « pas de contexte », le rôle NOBYPASSRLS recevait zéro ligne. Le handler ne trouvait pas l’organisation, décidait qu’il n’y avait rien à faire, répondait 200. L’abonnement n’était jamais persisté, et Stripe était content. La RLS faisait exactement ce qu’on lui demandait.
Côté RLS, la réponse a été une policy SELECT réservée au mode « pas de contexte », limitée au rattachement customer → organisation, et le même motif a resservi trois fois depuis pour d’autres traitements sans utilisateur. Le côté Stripe de l’histoire est dans ce qui casse quand Stripe passe en production.
Reste le pooler. AWS le signale au conditionnel : une variable de session posée avec SET peut être incompatible avec un pooling côté serveur comme PgBouncer. Ce que j’en comprends : en mode transaction, la connexion change de main entre deux transactions, et ce que tu as posé dessus ne t’appartient plus. Le '' du 19 mai sur asap.cool, c’est le cousin de ce problème, sans pooler externe : une connexion recyclée par le pool du client qui garde un résidu de la transaction d’avant.
La parade que j’utilise est un fait Postgres, pas une astuce : SET LOCAL pose le paramètre pour la transaction en cours et il disparaît à la fin de celle-ci. Si le contexte est posé dans la même transaction que la requête qu’il protège, un pooler en mode transaction ne peut rien mélanger, puisqu’il ne rend la connexion qu’entre deux transactions. C’est ce que fait le true de set_config(…, true). Et c’est précisément ce qui a produit mon '' du 19 mai : à la fin de la transaction, le paramètre local ne redevient pas inexistant, il redevient vide. D’où le NULLIF.
OrgTx : oublier l’isolation ne compile plus
Une fois tout ça en place, il restait un trou, et il était dans ma tête. set_org_context était une fonction à appeler au début de chaque transaction. L’oublier ne faisait pas d’erreur. Ça faisait une lecture fail-closed silencieuse, zéro ligne, comme le webhook, sauf que là c’était dans un écran métier, et le premier symptôme aurait été un client qui voit une liste vide.
Le 12 juillet 2026, j’ai remplacé la fonction par un type. OrgTx possède la transaction sqlx et pose le GUC d’organisation dans son constructeur. Il n’existe pas de OrgTx sans contexte, donc il n’existe pas de requête métier sans contexte : le code qui l’oublie ne compile pas. Le commit touche 128 fichiers, parce que chaque accès à la base a dû passer par le type.
Le patron tient en quelques lignes :
pub async fn begin(pool: &PgPool, org_id: Uuid) -> Result<Self, sqlx::Error> {
let mut tx = pool.begin().await?;
set_org_context(&mut tx, org_id).await?;
Ok(Self { tx })
}
La transaction n’est jamais rendue nue : elle sort du constructeur avec son contexte déjà posé, ou elle ne sort pas.
J’ai ajouté un cliquet sqlx au même moment. sqlx sait vérifier les requêtes à la compilation contre le schéma, et une partie du code ne l’utilisait pas encore. Un script en CI compte les requêtes non vérifiées et échoue si le nombre monte. Il ne peut que baisser, jamais remonter. Pour faire simple, je ne demande pas à la relecture de code d’attraper une requête oubliée, je demande au compilateur.
C’est ce que j’entends par « ce que je fais à la place ». Une discipline qui repose sur la mémoire du développeur, moi je ne la garde pas, je la transforme en quelque chose qui ne compile pas ou qui fait échouer le pipeline.
Optimo : le cloisonnement que le client fait tester
Sur Optimo France, un SaaS B2B pour pâtissiers mis en service après sept semaines, le cloisonnement des données financières par rôle n’était pas une bonne pratique que j’avais apportée. C’était dans le contrat, et il était testé. En avril 2026, j’ai migré 33 call sites pour passer chaque accès aux données financières par une couche qui vérifie le rôle et le tenant.
Optimo est sur Prisma, sans RLS : l’isolation y tient à la couche applicative, et c’est ce qui rend la revue indispensable. Le 23 avril, j’ai ajouté un filtre tenantId sur la requête priceHistory, alors qu’un garde la protégeait déjà en amont. Pas parce qu’une fuite avait eu lieu, mais parce qu’une requête qui dépend du garde qui la précède finit un jour réutilisée ailleurs, sans le garde. Défense en profondeur, ici, ça veut dire que chaque requête se protège seule. Sur un projet où le client demande la preuve et pas la promesse, c’est la seule réponse que j’ai.
GoPronos : le scoring qui vivait dans le navigateur
L’isolation des données ne suffit pas, et GoPronos me l’a rappelé le premier jour de reprise, le 22 mai 2026. C’est une app de pronostics prototypée sur Lovable, que je devais amener sur les stores. En 40 commits ce jour-là, j’ai sorti du client tout ce qui décidait de quelque chose. Le scoring était calculé côté navigateur, donc modifiable par le joueur qui ouvre les outils de dev, et rien ne verrouillait les pronostics au coup d’envoi ni ne cachait ceux des autres joueurs.
Les pronos sont maintenant verrouillés au coup d’envoi par la base, les picks des autres sont masqués par policy, le scoring tourne côté serveur, et has_role est une RPC plutôt qu’un champ que le client envoie. En gros, sur un SaaS, la question n’est pas seulement « qui voit quelles lignes », c’est aussi « qui décide de quoi », et la réponse à la seconde ne peut pas être « le navigateur ».
Rust ou Next : je ne mets pas la même stack partout
Tu as remarqué que je parle d’un produit en Rust et de clients en Next.js et Supabase. Ce n’est pas une incohérence, c’est le tri du début : le langage est une décision réversible, donc elle se prend selon qui maintiendra le code. asap.cool est un modulith Rust : 35 crates dont 11 modules de domaine dans une seule API, des frontières nommées, et à côté un worker qui ne parle à l’API que par la file, plus quelques services annexes qui passent par l’API publique. C’est le patron que Shopify décrit à mille développeurs, à l’échelle d’un seul.
Le prix de Rust, je l’ai chiffré. En juillet 2026, un build à froid d’asap.cool prenait 67 Go de disque et 37 minutes, et j’ai épuisé les minutes GitHub Actions avant de passer à cargo-chef puis à un script de déploiement manuel. En face, le 16 juillet, réécrire mon serveur MCP de TypeScript en Rust a fait passer sa mémoire résidente de 100 à 10 MiB et l’image de 373 à 102 Mo, à catalogue identique. Je paie en temps de compilation, je gagne en exploitation, et je suis le seul à maintenir ce code.
Pour un SaaS client, la question est différente : dans trois ans, ce code sera repris par une autre équipe, et elle trouvera plus facilement quelqu’un qui lit du Next.js et du Postgres que du Axum. Alors le défaut que je propose dans un SaaS sur-mesure, c’est Next, Postgres et une RLS forcée testée sous le rôle applicatif. Ennuyeux exprès, et la partie qui ne se défait pas, la tenancy, est traitée pareil que sur mon produit.
Ce que je fais sur asap.cool aujourd’hui
Sur asap.cool, l’état au moment où j’écris est celui-ci. 116 tables, dont 105 en FORCE ROW LEVEL SECURITY et 119 policies, un rôle applicatif NOBYPASSRLS qui ne possède aucune table. Toute requête métier passe par un OrgTx qui pose le contexte dans son constructeur, en local à la transaction, et le lit avec NULLIF(current_setting(…), '')::uuid. Les traitements sans utilisateur, webhooks et worker, passent par une policy système qui n’ouvre que la lecture dont ils ont besoin. Et le cliquet sqlx interdit au nombre de requêtes non vérifiées de remonter.
Ce qui tourne en CI chaque semaine, c’est le drill de restauration : une sauvegarde est rejouée sur une base vide et comparée, schéma, policies RLS et données comprises. Il ne bloque rien, il tourne le lundi matin ; mais un lundi rouge, je le vois, et un document de sauvegarde, personne ne le relit.
Si tu veux la suite de ce genre de retours, les incidents compris, c’est ce que j’envoie dans Sycode Dispatch.