Row Level Security uitgelegd: waarom je app klantdata lekt

Damian Madronal Munoz, oprichter

8 min leestijd

Het lek dat we het vaakst tegenkomen bij snel gebouwde applicaties is telkens hetzelfde: de database staat open. Niet omdat iemand een fout maakte in de code, maar omdat er nooit een regel is ingesteld die zegt wie wat mag zien. In Postgres, en dus in Supabase, heet dat mechanisme Row Level Security.

Dit stuk legt uit wat RLS is, waarom het bij een applicatie met een clouddatabase geen optie maar een noodzaak is, en hoe je in vijf minuten controleert of het bij jou goed staat.

Het probleem in één alinea

Bij een klassieke opzet praat je browser met een server, en die server praat met de database. De server bepaalt wat je te zien krijgt. Bij moderne clouddatabases praat je browser rechtstreeks met de database. Dat is sneller en scheelt werk, maar het betekent ook dat de laag die vroeger bepaalde wat jij mocht zien, er niet meer is. Die verantwoordelijkheid verhuist naar de database zelf.

Doe je daar niets mee, dan geldt: wie de sleutel van je frontend heeft (en die heeft iedere bezoeker, want hij staat in de broncode) kan alles opvragen. Niet alleen zijn eigen rijen. Alle rijen.

Wat Row Level Security doet

RLS verplaatst de vraag "mag deze gebruiker deze rij zien?" naar de database. Je zet het aan per tabel, en daarna geldt: alles is verboden, tenzij een policy het toestaat. Die omkering is precies wat je wil, want vergeten betekent dan dicht in plaats van open.

-- Zonder deze regel is elke policy hieronder zinloos.
alter table bestellingen enable row level security;
Stap één, en de stap die het vaakst wordt overgeslagen.

Let op de valkuil: het aanmaken van policies terwijl RLS uitstaat geeft geen foutmelding. Je ziet een keurige lijst met regels in je dashboard en denkt dat het geregeld is, terwijl er niets wordt afgedwongen.

Een policy schrijven

Een policy beschrijft welke rijen zichtbaar zijn voor wie. De meest voorkomende vorm: een gebruiker mag alleen zijn eigen rijen zien. In Supabase geeft de functie auth.uid() het id van de ingelogde gebruiker.

create policy "eigen bestellingen lezen"
  on bestellingen
  for select
  using (auth.uid() = gebruiker_id);
Lezen: alleen rijen waarvan het gebruiker_id overeenkomt met wie er inlogt.

Belangrijk: dit dekt alleen lezen. Toevoegen, wijzigen en verwijderen zijn aparte operaties met aparte policies. Een applicatie waarin lezen dichtgetimmerd is maar iedereen mag schrijven, komt vaker voor dan je zou hopen.

create policy "eigen bestellingen toevoegen"
  on bestellingen
  for insert
  with check (auth.uid() = gebruiker_id);

create policy "eigen bestellingen wijzigen"
  on bestellingen
  for update
  using (auth.uid() = gebruiker_id)
  with check (auth.uid() = gebruiker_id);
using bepaalt welke rijen je mag raken, with check wat je mag achterlaten.

Het verschil tussen using en with check

Dit onderscheid kost mensen de meeste tijd, dus kort: using wordt toegepast op rijen zoals ze nú zijn, with check op rijen zoals ze ná de bewerking zouden zijn. Bij een update heb je allebei nodig. Laat je with check weg, dan mag een gebruiker zijn eigen rij pakken en er andermans gebruiker_id in zetten. Daarmee geeft hij hem weg, of erger: hij neemt hem over.

De drie fouten die we het vaakst zien

  1. RLS staat uit terwijl er wel policies zijn. Alles ligt open, en het dashboard oogt geruststellend.
  2. Een policy met using (true). Dat betekent letterlijk "iedereen mag alles" en wordt vaak tijdens het bouwen ingezet om verder te kunnen, en dan vergeten.
  3. Alleen een select-policy. Lezen is dicht, maar insert, update en delete staan open omdat daar niets voor is ingesteld.

Alle drie zijn ze te vinden zonder één regel code te schrijven, en dat is precies wat de volgende paragraaf doet.

Zelf controleren in vijf minuten

Draai deze query in de SQL-editor van je database. Hij laat per tabel zien of RLS aanstaat en hoeveel policies er zijn.

select
  c.relname                          as tabel,
  c.relrowsecurity                   as rls_aan,
  count(p.polname)                   as aantal_policies
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind = 'r'
group by c.relname, c.relrowsecurity
order by c.relrowsecurity, c.relname;
Elke regel met rls_aan = false is een tabel die openstaat.

Staat er ergens false, dan is die tabel volledig leesbaar voor iedereen met je publieke sleutel. Staat rls_aan op true maar is het aantal policies nul, dan is het tegenovergestelde waar: niemand komt erbij, ook je eigen applicatie niet. Dat is minder erg, maar het verklaart waarom er soms opeens niets meer werkt.

Waar je nog meer op moet letten

RLS goed instellen is de basis, niet het hele verhaal. Twee dingen die er direct naast horen.

Ten eerste: de service-sleutel. Elke clouddatabase heeft een sleutel die álle policies negeert, bedoeld voor serverwerk. Die hoort nooit in je frontend, in je repository of in een omgevingsvariabele die naar de browser wordt gestuurd. Staat hij daar wel, dan maakt het niet uit hoe goed je policies zijn.

Ten tweede: views. Een view op een tabel met RLS erft die beveiliging niet automatisch zoals je zou verwachten. Bouw je views over gevoelige tabellen, controleer dan expliciet wie daar doorheen kan kijken.

En tenslotte: test het als aanvaller, niet als eigenaar. Log uit, of log in als een tweede testgebruiker, en probeer de gegevens van de eerste op te halen. Krijg je ze te zien, dan weet je genoeg. Dat is een test van vijf minuten die je in je hoofd nooit goed doet. Bij een webapplicatie die wij bouwen staan die policies er vanaf de eerste versie in.

Wij zeggen concreet wat er open staat⁠

We kijken naar je policies en je sleutels en zeggen concreet wat er open staat. Ook als het antwoord is dat het goed geregeld is.

Vraag een gratis check aan

Veelgestelde vragen⁠

Wat is Row Level Security precies?

Een mechanisme in Postgres waarmee de database zelf per rij bepaalt of een gebruiker die mag zien of wijzigen. Zet je het aan op een tabel, dan is standaard alles verboden totdat je met policies beschrijft wat wel mag. Dat is precies andersom dan de meeste mensen verwachten, en dat is het punt: vergeten leidt tot dicht, niet tot open.

Heb ik RLS nodig als mijn app geen inlog heeft?

Als je browser rechtstreeks met de database praat: ja, altijd. Zonder inlog is iedereen dezelfde anonieme gebruiker, en zonder policies kan die alles lezen wat er in staat. Wil je bijvoorbeeld alleen gepubliceerde artikelen tonen, dan schrijf je daar een policy voor in plaats van erop te vertrouwen dat je frontend niets anders opvraagt.

Is de publieke sleutel van Supabase geheim?

Nee, en dat hoeft ook niet. De anon-sleutel is ontworpen om in de browser te staan; hij geeft alleen toegang tot wat je policies toestaan. Dat is meteen waarom die policies er moeten zijn. De service-sleutel is een ander verhaal: die negeert alle policies en hoort uitsluitend op een server te staan.

Vertragen policies mijn applicatie?

Meestal nauwelijks, maar het kan als een policy een zware voorwaarde bevat, bijvoorbeeld een subquery over een grote tabel. De praktische vuistregel: zorg dat de kolommen waarop je policies filteren, vaak gebruiker_id, geïndexeerd zijn. Dan is het effect in de regel verwaarloosbaar.

Wij kijken je toegangsregels na⁠

Gratis en vrijblijvend. We reageren binnen één werkdag.