PostgreSQL row-level security voor SaaS helpt tenant-isolatie in de database afdwingen. Leer wanneer je het gebruikt, hoe je policies schrijft en wat je moet vermijden.

In een SaaS-app is de meest gevaarlijke beveiligingsbug degene die opdook zodra je op schaal kwam. Je begint met een eenvoudige regel zoals “gebruikers mogen alleen de data van hun tenant zien”, vervolgens lanceer je snel een nieuw endpoint, voeg je een rapportagequery toe, of introduceer je een join die stilletjes de check overslaat.
Autoratie alleen in de applicatie faalt onder druk omdat de regels verspreid raken. De ene controller controleert tenant_id, een andere controleert membership, een background job vergeet het, en een “admin export” pad blijft maandenlang “tijdelijk”. Zelfs aandachtige teams missen wel eens een plek.
PostgreSQL row-level security (RLS) lost een concreet probleem op: het laat de database afdwingen welke rijen zichtbaar zijn voor een bepaald request. Het mentale model is simpel: elke SELECT, UPDATE en DELETE wordt automatisch gefilterd door policies, net zoals elk request gefilterd wordt door authenticatie-middleware.
Het woord “rijen” doet ertoe. RLS beschermt niet alles:
Een concreet voorbeeld: je voegt een endpoint toe dat projecten opsomt met een join naar facturen voor een dashboard. Met alleen app-auth is het makkelijk om projects te filteren op tenant maar te vergeten invoices te filteren, of te joinen op een sleutel die tenants kruist. Met RLS kunnen beide tabellen tenant-isolatie afdwingen, zodat de query veilig faalt in plaats van data te lekken.
De afweging is reëel. Je schrijft minder herhaalde autorisatiecode en vermindert het aantal plekken dat kan lekken. Maar je krijgt ook nieuw werk: je moet policies zorgvuldig ontwerpen, vroeg testen, en accepteren dat een policy een query kan blokkeren die je had verwacht dat werkte.
RLS kan voelen als extra werk totdat je app groter wordt dan een handvol endpoints. Als je strikte tenant-grenzen hebt en veel query-paden (lijstschermen, zoeken, exports, admin-tools), betekent het neerleggen van de regel in de database dat je je niet overal dezelfde filter hoeft te herinneren.
RLS past goed wanneer de regel saai en universeel is: “een gebruiker mag alleen rijen van zijn tenant zien” of “een gebruiker mag alleen projecten zien waarvan hij lid is.” In die situaties verminderen policies fouten omdat elke SELECT, UPDATE en DELETE door dezelfde poort gaat, zelfs wanneer later een query wordt toegevoegd.
Het helpt ook in read-heavy apps waar de filterlogica consistent blijft. Als je API 15 verschillende manieren heeft om facturen te laden (op status, datum, klant, zoekopdracht), laat RLS je stoppen met het telkens opnieuw implementeren van tenant-filtering en kun je op de feature focussen.
RLS brengt pijn als de regels niet rijniveau-gebaseerd zijn. Per-veld regels zoals “je mag salaris zien maar niet bonus” of “maskeer deze kolom tenzij je HR bent” veranderen vaak in onhandige SQL en moeilijk onderhoudbare uitzonderingen.
Het is ook lastig voor zware rapportages die echt brede toegang nodig hebben. Teams maken dan vaak omgevingen of bypass-rollen voor “gewoon dit ene jobje”, en daar stapelen fouten zich op.
Voordat je commit: beslis of je de database de laatste poortwacht wilt laten zijn. Zo ja, plan voor discipline: test database-gedrag (niet alleen API-responses), behandel migraties als security-wijzigingen, vermijd snelle bypasses, bepaal hoe background jobs authenticeren en houd policies klein en herhaalbaar.
Als je tooling gebruikt die backends genereert, kan dat de levering versnellen, maar het neemt de noodzaak voor duidelijke rollen, tests en een eenvoudig tenantmodel niet weg. (Bijvoorbeeld: Koder.ai gebruikt Go en PostgreSQL voor gegenereerde backends, en je wilt RLS bewust ontwerpen in plaats van het “er later bij te strooien”.)
RLS is het makkelijkst wanneer je schema al duidelijk aangeeft wie wat bezit. Als je begint met een vaag model en probeert dat “in policies te repareren”, krijg je meestal trage queries en verwarrende bugs.
Kies één tenant-sleutel (zoals org_id) en gebruik die consequent. De meeste tenant-owned tabellen zouden die moeten hebben, zelfs als ze ook verwijzen naar een andere tabel die hem al heeft. Dit voorkomt joins binnen policies en houdt USING checks eenvoudig.
Een praktische regel: als een rij moet verdwijnen als een klant opzegt, heeft het waarschijnlijk org_id nodig.
RLS-policies beantwoorden meestal één vraag: “Is deze gebruiker lid van deze org, en wat mag hij doen?” Dat is moeilijk af te leiden uit ad-hoc kolommen.
Houd de kern-tabellen klein en eenvoudig:
users (één rij per persoon)orgs (één rij per tenant)org_memberships (user_id, org_id, role, status)project_memberships voor per-project toegangMet dat in plaats kunnen je policies membership checken met één geindexeerde lookup.
Niet alles heeft org_id nodig. Referentietabellen zoals landen, productcategorieën of plantypes zijn vaak gedeeld tussen alle tenants. Maak ze read-only voor de meeste rollen en koppel ze niet aan één org.
Tenant-owned data (projects, invoices, tickets) moet vermijden tenant-specifieke details via gedeelde tabellen naar binnen te halen. Houd gedeelde tabellen minimaal en stabiel.
Foreign keys werken nog steeds met RLS, maar deletes kunnen je verrassen als de deleter-rol afhankelijke rijen niet “kan zien”. Plan cascades zorgvuldig en test echte delete-flows.
Indexeer de kolommen waarop je policies filteren, vooral org_id en membership-sleutels. Een policy die leest als WHERE org_id = ... moet geen full-table scan worden wanneer de tabel miljoenen rijen bevat.
RLS is een per-tabel schakel. Zodra het aanstaat, stopt PostgreSQL het vertrouwen in je app-code om de tenant-filter te onthouden. Elke SELECT, UPDATE en DELETE wordt gefilterd door policies, en elke INSERT en UPDATE wordt gevalideerd door policies.
De grootste mentale verschuiving: met RLS aan kunnen queries die vroeger data teruggaven, nu nul rijen teruggeven zonder foutmelding. Dat is PostgreSQL die toegang regelt.
Policies zijn kleine regels gekoppeld aan een tabel. Ze gebruiken twee checks:
USING is het read-filter. Als een rij niet voldoet aan USING, is die onzichtbaar voor SELECT, en kan die niet doelwit zijn van UPDATE of DELETE.WITH CHECK is de write-poort. Het bepaalt welke nieuwe of gewijzigde rijen zijn toegestaan voor INSERT of UPDATE.Een veelvoorkomend SaaS-patroon: USING zorgt dat je alleen rijen van je tenant ziet, en WITH CHECK zorgt dat je geen rij in iemand anders zijn tenant kunt invoegen door een tenant-id te raden.
Als je later meer policies toevoegt, doet dit ertoe:
PERMISSIVE (standaard): een rij is toegestaan als enige policy hem toestaat.RESTRICTIVE: een rij is toegestaan alleen als alle restrictieve policies hem toestaan (bovenop permissive gedrag).Als je van plan bent regels te lagen zoals tenant-match plus rolchecks plus project-membership, kunnen restrictive policies intentie duidelijker maken, maar ze maken het ook makkelijker jezelf uit te sluiten als je één voorwaarde vergeet.
RLS heeft een betrouwbare “wie roept aan” waarde nodig. Gebruikelijke opties:
app.user_id en app.tenant_id).SET ROLE ... per request), wat kan werken maar operationele overhead toevoegt.Kies één aanpak en pas die overal toe. Het mixen van identity-bronnen over services is een snelle weg naar verwarrende bugs.
Gebruik een voorspelbare conventie zodat schema-dumps en logs leesbaar blijven. Bijvoorbeeld: {table}__{action}__{rule}, zoals projects__select__tenant_match.
Als je nieuw bent met RLS, begin met één tabel en een klein bewijs. Het doel is niet perfecte dekking. Het doel is dat de database cross-tenant toegang weigert, zelfs als er een app-bug is.
Stel een eenvoudige projects tabel voor. Voeg eerst tenant_id toe op een manier die writes niet breekt.
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
Scheer daarna ownership en toegang van elkaar. Een gangbaar patroon is: één rol bezit tabellen (app_owner), een andere rol wordt door de API gebruikt (app_user). De API-rol zou niet de table owner moeten zijn, anders kan die policies omzeilen.
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
Bepaal nu hoe het request Postgres vertelt welke tenant het bedient. Eén eenvoudige aanpak is een request-gescopeerde setting. Je app zet die meteen nadat een transactie geopend is.
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
Zet RLS aan en begin met lees-toegang.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Bewijs dat het werkt door twee verschillende tenants uit te proberen en te kijken of het aantal rijen verandert.
Read-policies beschermen writes niet. Voeg WITH CHECK toe zodat inserts en updates geen rijen in de verkeerde tenant smokkelen.
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Een snelle manier om gedrag (inclusief fouten) te verifiëren is een klein SQL-script dat je na elke migratie kunt herhalen:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '<tenant A>', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '<tenant B>', 'bad'); (moet falen)UPDATE projects SET tenant_id = '<tenant B>' WHERE ...; (moet falen)ROLLBACK;Als je dat script kunt draaien en elke keer dezelfde resultaten krijgt, heb je een betrouwbaar uitgangspunt voordat je RLS naar andere tabellen uitbreidt.
De meeste teams adopteren RLS als ze genoeg hebben van het steeds opnieuw herhalen van dezelfde autorisatiechecks in elke query. Het goede nieuws is dat de policy-vormen die je nodig hebt meestal consistent zijn.
Sommige tabellen zijn van nature door één gebruiker bezet (notes, API-tokens). Andere behoren toe aan een tenant waar toegang afhankelijk is van membership. Behandel deze als verschillende patronen.
Voor owner-only data controleren policies vaak created_by = app_user_id(). Voor tenant-data controleren policies vaak of de gebruiker een membership-rij voor de org heeft.
Een praktische manier om policies leesbaar te houden is identiteit te centraliseren in kleine SQL-helpers en die hergebruiken:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
Reads zijn vaak breder dan writes. Bijvoorbeeld: ieder org-lid kan SELECT projecten, maar alleen editors kunnen UPDATE, en alleen owners kunnen DELETE.
Maak het expliciet: één policy voor SELECT (membership), één policy voor INSERT/UPDATE met WITH CHECK (rol), en één voor DELETE (vaak strikter dan update).
Vermijd “zet RLS uit voor admins.” Voeg liever een escape-hatch binnen policies toe, zoals app_is_admin(), zodat je niet per ongeluk volledige toegang geeft aan een gedeelde serviceroom.
Als je deleted_at of status gebruikt, verwerk dat in de SELECT policy (deleted_at is null). Anders kan iemand rijen “weer tot leven brengen” door flags te flippen waar de app van uitging dat ze definitief waren.
WITH CHECK vriendelijkINSERT ... ON CONFLICT DO UPDATE moet voldoen aan WITH CHECK voor de rij ná de write. Als je policy vereist created_by = app_user_id(), zorg dat je upsert created_by zet bij insert en het niet overschrijft bij update.
Als je backend code genereert, zijn deze patronen de moeite waard om in interne templates te gieten zodat nieuwe tabellen met veilige defaults beginnen in plaats van een blanco vel.
RLS is geweldig totdat één klein detail het lijkt alsof PostgreSQL “willekeurig” data verbergt of toont. De fouten hieronder kosten de meeste tijd.
De eerste val is het vergeten van WITH CHECK op insert en update. USING bepaalt wat je kunt zien, niet wat je mag creëren of wijzigen. Zonder WITH CHECK kan een app-bug een rij in de verkeerde tenant schrijven, en je merkt het misschien niet omdat diezelfde gebruiker die rij niet kan teruglezen.
Een andere veelvoorkomende lek is de “leaky join.” Je filtert projects correct, maar joinkt naar invoices, notes of files die niet op dezelfde manier beschermd zijn. De remedie is strikt maar eenvoudig: elke tabel die tenant-data kan onthullen heeft zijn eigen policy nodig, en views mogen niet alleen afhankelijk zijn van één veilige tabel.
Veelvoorkomende foutpatronen verschijnen vroeg:
WITH CHECK voor writes.Policies die dezelfde tabel refereren (direct of via een view) kunnen recursie-surprises opleveren. Een policy kan membership checken door een view te queryen die de beschermde tabel weer leest, wat leidt tot fouten, trage queries of een policy die nooit matcht.
Role-setup is een andere bron van verwarring. Table owners en verhoogde rollen kunnen RLS omzeilen, dus je tests slagen terwijl echte gebruikers falen (of andersom). Test altijd met dezelfde laag-rechten rol die je app ook gebruikt.
Wees voorzichtig met SECURITY DEFINER functies. Ze draaien met de privileges van de functeeigenaar, dus een helper zoals current_tenant_id() kan prima zijn, maar een “handige” functie die data leest kan per ongeluk over tenants heen lezen tenzij je hem zo ontwerpt dat hij RLS respecteert.
Stel ook een veilige search_path in binnen security definer-functies. Als je dat niet doet, kan de functie een ander object met dezelfde naam oppikken, en je policy-logica kan stilletjes naar het verkeerde object wijzen afhankelijk van de sessiestatus.
RLS-bugs komen meestal door ontbrekende context, niet door “slechte SQL.” Een policy kan op papier correct zijn en toch falen omdat de session role anders is dan je denkt, of omdat het request nooit de tenant- en gebruikerwaarden zette waarop de policy vertrouwt.
Een betrouwbare manier om een productie-rapport te reproduceren is dezelfde sessie-setup lokaal te spiegelen en de exacte query uit te voeren. Dat betekent meestal:
SET ROLE app_user; (of de echte API-rol)SELECT set_config('app.tenant_id', 't_123', true); en SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);Als je niet zeker weet welke policy toegepast wordt, kijk dan in de catalog in plaats van te raden. pg_policies toont elke policy, het commando en de USING en WITH CHECK expressies. Koppel dat aan pg_class om te bevestigen dat RLS op de tabel aanstaat en niet omzeild wordt.
Prestatieproblemen kunnen lijken op auth-problemen. Een policy die een membership-tabel joint of een functie aanroept kan correct maar traag zijn zodra de tabel groeit. Gebruik EXPLAIN (ANALYZE, BUFFERS) op de gereproduceerde query en kijk naar sequentiële scans, onverwachte nested loops of filters die laat worden toegepast. Ontbrekende indexes op (tenant_id, user_id) en membership-tabellen zijn veelvoorkomende oorzaken.
Het helpt ook om drie waarden per request te loggen op applicatieniveau: de tenant ID, de user ID en de database-rol gebruikt voor het request. Als die niet overeenkomen met wat je dacht te hebben gezet, zal RLS zich “verkeerd” gedragen omdat de inputs verkeerd zijn.
Voor tests, houd een paar seed-tenants en maak failures expliciet. Een kleine suite bevat meestal: “Tenant A kan Tenant B niet lezen,” “gebruiker zonder membership kan het project niet zien,” “owner kan updaten, viewer niet,” “insert wordt geblokkeerd tenzij tenant_id overeenkomt met context,” en “admin override geldt alleen waar bedoeld.”
Behandel RLS als een veiligheidsriem, niet als een feature-toggle. Kleine missers veranderen in “iedereen ziet ieders data” of “alles geeft nul rijen terug.”
Zorg dat je tabelontwerp en policy-regels bij je tenant-model passen.
tenant_id). Als die ontbreekt, schrijf op waarom (bijv. globale referentietabellen).FORCE ROW LEVEL SECURITY op die tabellen.USING. Writes moeten WITH CHECK hebben zodat inserts en updates een rij niet in een andere tenant kunnen plaatsen.tenant_id of joinen via membership-tabellen, voeg de bijbehorende indexes toe.Een simpele sanity-scenario: een tenant A gebruiker kan zijn eigen facturen lezen, kan alleen een factuur invoegen voor tenant A, en kan een factuur niet updaten om tenant_id te veranderen.
RLS is zo sterk als de rollen die je app gebruikt.
bypassrls connecteert.Stel je een B2B-app voor waar bedrijven (orgs) projecten hebben, en projecten taken. Gebruikers kunnen bij meerdere orgs horen, en een gebruiker kan lid zijn van sommige projecten maar niet van andere. Dit is een goede match voor RLS omdat de database tenant-isolatie kan afdwingen, zelfs als een API-endpoint een filter vergeet.
Een eenvoudig model is: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). Die org_id op tasks is intentioneel. Het houdt policies simpel en vermindert verrassingen tijdens joins.
Een klassiek lek gebeurt wanneer tasks alleen project_id hebben, en je policy toegang controleert via een join naar projects. Eén fout (een permissive policy op projects, een join die een conditie weglaat, of een view die context verandert) kan taken van een andere org blootstellen.
Een veiligere migratiepad vermijdt het breken van productieverkeer:
org_id toe aan tasks, voeg membership-tabellen toe).tasks.org_id uit projects.org_id, en voeg daarna NOT NULL toe.Support-toegang kun je het beste afhandelen met een smalle break-glass rol, niet door RLS uit te schakelen. Houd die gescheiden van normale support-accounts en maak expliciet wanneer hij gebruikt wordt.
Documenteer de regels zodat policies niet verschuiven: welke session-variabelen moeten gezet worden (user_id, org_id), welke tabellen org_id moeten dragen, wat “member” betekent, en een paar SQL-voorbeelden die 0 rijen zouden moeten teruggeven als ze als de verkeerde org uitgevoerd worden.
RLS is het makkelijkst als je het als een productwijziging behandelt. Rol het uit in kleine stukken, bewijs gedrag met tests en houd een duidelijk verslag waarom elke policy bestaat.
Een rollout-plan dat vaak werkt:
projects) en sluit die af.Nadat de eerste tabel stabiel is, maak policy-wijzigingen doelbewust. Voeg een policy-review stap toe aan migraties en voeg een korte intentie-notitie toe (wie mag wat en waarom) plus een bijpassende test-update. Dit voorkomt “voeg gewoon nog een OR toe” policies die langzaam in een lek veranderen.
Als je snel beweegt, kunnen tools zoals Koder.ai (koder.ai) je helpen een Go + PostgreSQL startpunt te genereren via chat, en dan kun je RLS-policies en tests met dezelfde discipline bovenop leggen als bij een handmatig opgebouwde backend.
Tot slot: houd veiligheidsvoorzieningen tijdens rollout. Maak snapshots vóór policy-migraties, oefen rollback totdat het saai wordt, en houd een kleine break-glass route voor support die RLS niet over het hele systeem uitschakelt.
RLS laat PostgreSQL afdwingen welke rijen zichtbaar of schrijfbaar zijn voor een request, zodat tenant-isolatie niet afhangt van dat elke endpoint de juiste WHERE tenant_id = ... filter onthoudt. Het grootste voordeel is dat je minder kwetsbaar bent voor “één vergeten check” fouten wanneer je app groeit en queries toenemen.
Het is de moeite waard wanneer toegangsregels consistent en rijniveau-gericht zijn, zoals tenant-isolatie of toegangscontrole op basis van membership, en je veel verschillende query-paden hebt (zoeken, exports, adminschermen, background jobs). Het is meestal minder zinvol als de regels vooral per-veld zijn, veel uitzonderingen kennen of wanneer rapportages breed over tenants moeten lezen.
RLS zorgt voor rijniveau zichtbaarheid en basis write-gating. Kolomprivacy vraagt vaak om views en kolomrechten, en complexe businessregels (zoals facturerings-eigendom of goedkeuringsstromen) horen meestal thuis in applicatielogica of in zorgvuldig ontworpen databaseconstraints.
Maak een laag-privilege rol voor de API (niet de table owner), zet RLS aan, voeg dan een SELECT policy en een INSERT/UPDATE policy met WITH CHECK toe. Gebruik een request-gescopeerde sessievariabele (zoals app.current_tenant) en verifieer dat het wisselen daarvan verandert welke rijen je kunt zien en schrijven.
Een veelgebruikte manier is een sessievariabele per request, ingesteld aan het begin van de transactie, zoals app.tenant_id en app.user_id. Belangrijk is consistentie: elk codepad (web-requests, jobs, scripts) moet dezelfde waarden zetten waar policies op vertrouwen, anders krijg je verwarrend “zero rows” gedrag.
USING bepaalt welke bestaande rijen zichtbaar en targetable zijn voor SELECT, UPDATE en DELETE. WITH CHECK bepaalt welke nieuwe of gewijzigde rijen toegestaan zijn tijdens INSERT en UPDATE, zodat je voorkomt dat er per ongeluk in een andere tenant geschreven wordt.
Als je alleen USING toevoegt, kan een bug in een endpoint alsnog rijen schrijven in de verkeerde tenant en je merkt het misschien niet omdat dezelfde gebruiker die rij niet terug kan lezen. Koppel altijd een tenant-readregel aan een bijpassende WITH CHECK regel voor writes zodat slechte data niet gemaakt kan worden.
Vermijd joins binnen policies door de tenant-sleutel (zoals org_id) direct op tenant-gebruikte tabellen te zetten, zelfs als ze ook naar een andere tabel verwijzen die die sleutel heeft. Voeg expliciete membership-tabellen toe (org_memberships, optioneel project_memberships) zodat policies met één geindexeerde lookup kunnen werken in plaats van ingewikkelde afleidingen.
Reproduceer eerst dezelfde sessiecontext die je app gebruikt door dezelfde rol en sessie-instellingen te zetten en voer dan exact de SQL uit. Controleer daarna of RLS aanstaat en inspecteer pg_policies om te zien welke USING en WITH CHECK expressies van toepassing zijn — RLS faalt vaak omdat de identiteitcontext ontbreekt, niet door “slechte SQL.”
Ja, maar beschouw gegenereerde code als uitgangspunt, niet als complete beveiliging. Als je Koder.ai gebruikt om een Go + PostgreSQL backend te genereren, moet je nog steeds je tenant-model definiëren, sessie-identiteit consistent zetten en policies en tests bewust toevoegen zodat nieuwe tabellen niet zonder juiste bescherming worden opgeleverd.