Supabase RLS controleren: kan iedereen je tabellen lezen?

Vraag je tabellen op met de publieke key uit je eigen frontend, zonder in te loggen, en daarna met het account van een tweede testgebruiker. Krijg je rijen terug die niet van die gebruiker zijn, dan staat de tabel open, ook als het dashboard zegt dat RLS aanstaat. Kijk daarna naar je opslag: bestanden in een openbare bucket kan iedereen met de link downloaden.

Damian Madronal Munoz, oprichter

7 min leestijd

Een tabel die openstaat, zie je niet in je app en niet in je dashboard. Je ziet hem pas als je kijkt zoals een buitenstaander: met de key uit je frontend, zonder account, of met het account van iemand anders. Hieronder doe je dat, met de verzoeken en de SQL erbij.

Wat Row Level Security is en hoe je een policy schrijft, staat in Row Level Security uitgelegd. Deze gids is de controle: of wat je hebt ingesteld ook werkt, per rol en per tabel.

Herken je dit?

  • Je app is met Lovable, Bolt of v0 op Supabase gebouwd, en je hebt de policies nooit zelf gelezen.
  • In het dashboard staat bij elke tabel dat RLS aanstaat, maar je weet niet wat de policies toestaan.
  • Iedereen kan zich met een e-mailadres aanmelden, en je hebt nooit gekeken wat een vers account te zien krijgt.
  • Gebruikers uploaden bestanden: facturen, foto’s, contracten.
  • Een AI-builder heeft ooit een fout als “permission denied” voor je opgelost, en je weet niet hoe.

Je eigen app is de slechtste test

Als je je app test, ben je ingelogd als jezelf, en je frontend vraagt alleen jouw rijen op. Ergens in de code staat een filter als .eq('gebruiker_id', user.id). Het scherm klopt dus. Of de database ook rijen van anderen zou geven, zie je niet, want de app vraagt er niet om. Een bezoeker met een eigen verzoek laat dat filter gewoon weg.

De SQL-editor in het dashboard misleidt op een andere manier. Daar werk je als eigenaar van de database, en voor de eigenaar gelden je policies niet. Je ziet er altijd alles, of de tabel nu dicht zit of niet. En een policy kan er staan en toch alles toestaan: het dashboard toont dat er een regel is, niet wat die regel doet.

Zo controleer je het, rol voor rol

1. Pak de URL en de key die je frontend gebruikt

Open je live app, open de ontwikkelaarstools van je browser (F12) en kies het tabblad Netwerk. Klik iets aan dat gegevens laadt. Je ziet verzoeken naar een adres als abcd1234.supabase.co/rest/v1/. In de headers staat bij apikey de key die elke bezoeker heeft.

Vergelijk hem met de keys in de API-instellingen van je project. Het hoort de publishable key te zijn (begint met sb_publishable_) of de oude anon-key. Staat er een secret key (sb_secret_) of de service_role-key, stop dan hier. Met die key gelden geen policies, dus de tests hieronder zeggen niets, en de key is uitgelekt. Wat je dan doet: API-key gelekt.

2. Laat de Security Advisor eerst kijken

In het dashboard staat onder Advisors de Security Advisor. Hij vindt tabellen zonder RLS, policies die altijd waar zijn en openbare buckets waarvan je de bestanden kunt opsommen. Los eerst op wat hij als error markeert. Of je regels passen bij wat jouw app doet, test hij niet.

3. Vraag elke tabel op als bezoeker zonder account

# Met een publishable key (begint met sb_publishable_)
curl "https://abcd1234.supabase.co/rest/v1/klanten?select=*&limit=5" \
  -H "apikey: sb_publishable_JOUWKEY"

# Met een oude anon-key (begint met eyJ): stuur hem ook als Bearer mee
curl "https://abcd1234.supabase.co/rest/v1/klanten?select=*&limit=5" \
  -H "apikey: eyJhbGciOi..." \
  -H "Authorization: Bearer eyJhbGciOi..."
Vraagt vijf rijen uit de tabel klanten op, zoals een bezoeker zonder account dat kan.

Krijg je namen, e-mailadressen of bestellingen terug, dan kan iedereen ze ophalen. Een lege lijst [] of een fout met permission denied is goed, zolang er wel rijen in de tabel staan. Doe dit voor elke tabel in het schema public, ook voor tabellen die je app nergens toont.

4. Probeer als tweede gebruiker bij de eerste te komen

Maak twee testaccounts, A en B, en geef A via de app een bestelling. Log daarna als B in via de API en probeer met het token van B de rijen van A te lezen en te wijzigen.

# 1. Log in als B en kopieer access_token uit het antwoord
curl -X POST "https://abcd1234.supabase.co/auth/v1/token?grant_type=password" \
  -H "apikey: sb_publishable_JOUWKEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "test-b@jouwdomein.nl", "password": "wachtwoord-van-b"}'

# 2. Lees als B de bestellingen van A
curl "https://abcd1234.supabase.co/rest/v1/bestellingen?select=*&gebruiker_id=eq.ID-VAN-A" \
  -H "apikey: sb_publishable_JOUWKEY" \
  -H "Authorization: Bearer TOKEN-VAN-B"

# 3. Wijzig als B een bestelling van A
curl -X PATCH "https://abcd1234.supabase.co/rest/v1/bestellingen?id=eq.BESTELLING-VAN-A" \
  -H "apikey: sb_publishable_JOUWKEY" \
  -H "Authorization: Bearer TOKEN-VAN-B" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"status": "geannuleerd"}'
Haalt een token voor B op en probeert daarmee een bestelling van A te lezen en te wijzigen.

Je wilt twee keer [] zien. Komt de bestelling van A terug, dan kan elke ingelogde gebruiker bij die van iedereen. Het id van A staat in het dashboard onder Authentication.

Heeft je app een kolom als rol of is_admin, test dan ook of B die bij zichzelf kan aanpassen, met hetzelfde PATCH-verzoek op zijn eigen profiel en {"rol": "admin"} als inhoud. Lukt dat, dan kan iedereen zichzelf beheerder maken. Haal het recht om die kolom te wijzigen weg:

revoke update on profielen from anon, authenticated;
grant update (naam, telefoon) on profielen to authenticated;
Gebruikers mogen hun profiel blijven wijzigen, behalve de kolom rol.

5. Herhaal het in de SQL-editor met een andere rol

In de SQL-editor van Supabase kun je een query draaien als een andere rol: anon, of authenticated als een bepaalde gebruiker. Kies die rol in het rolmenu van de editor in plaats van de standaard. Werk je met psql of een andere databaseclient, dan doet de SQL hieronder hetzelfde. Door de rollback aan het eind blijft er niets veranderd.

begin;
set local role anon;
select count(*) from klanten;            -- hoort 0 te zijn
rollback;

begin;
set local role authenticated;
set local request.jwt.claim.sub = 'ID-VAN-B';
select gebruiker_id, count(*) from bestellingen group by gebruiker_id;
update bestellingen set status = 'test' where gebruiker_id = 'ID-VAN-A';
delete from bestellingen where gebruiker_id = 'ID-VAN-A';
rollback;
Leest en wijzigt als bezoeker en als gebruiker B, en draait alles terug.

Bij de tweede select hoort één regel terug te komen: die van B. Bij de update en de delete wil je UPDATE 0 en DELETE 0 zien. Elk getal boven nul is een rij van A die B kan aanpassen of weghalen.

6. Zoek policies die te veel toestaan

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');

select tablename, policyname, qual, with_check
from pg_policies
where qual ilike '%user_meta%' or with_check ilike '%user_meta%';
Eerste query: policies die altijd waar zijn. Tweede: policies die een rol uit user_metadata lezen.

Kijk bij elke regel van de eerste query naar de kolom roles. Staat daar {anon} of {public}, dan geldt de policy ook voor bezoekers zonder account; bij {authenticated} voor iedereen met een account. Voor gepubliceerde producten kan dat kloppen, voor klanten en bestellingen nooit. Elke regel van de tweede query is een fout; waarom, staat bij de valkuilen.

7. Controleer je opslag

Openbare buckets slaan de policies over voor downloaden: wie de link heeft, krijgt het bestand. En bestandsnamen zijn vaak te raden, zoals facturen/2026-0042.pdf.

select id, public from storage.buckets;

select policyname, cmd, roles, qual
from pg_policies
where schemaname = 'storage' and tablename = 'objects';

curl -X POST "https://abcd1234.supabase.co/storage/v1/object/list/documenten" \
  -H "apikey: sb_publishable_JOUWKEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix": "", "limit": 20}'
Toont welke buckets openbaar zijn, welke policies er gelden, en of een bezoeker de bestanden in de bucket documenten kan opsommen.

Facturen, contracten en kopieën van identiteitsbewijzen horen in een bucket die niet openbaar is, met een policy die elke gebruiker alleen zijn eigen map geeft. Gebruik je de oude anon-key, zet dan bij het laatste verzoek ook de Authorization-regel uit stap 3.

create policy "eigen map lezen"
  on storage.objects for select
  to authenticated
  using (
    bucket_id = 'documenten'
    and (storage.foldername(name))[1] = auth.uid()::text
  );
Elke gebruiker leest alleen bestanden in de map met zijn eigen id.

Waar het bij de controle misgaat

  1. Ingelogd behandelen als vertrouwd. Een policy to authenticated using (true) klinkt streng, maar staat aanmelden open, dan is iedereen met een e-mailadres ingelogd. Kijk in de instellingen van Auth of nieuwe gebruikers zich zelf mogen aanmelden, en schrijf policies op eigendom, niet op ingelogd zijn.
  2. De rol uit user_metadata halen. Een gebruiker kan zijn eigen user_metadata vanuit de browser aanpassen. Een policy die daar admin in zoekt, laat iedereen beheerder worden. Zet rollen in app_metadata, die alleen de server kan schrijven, of in een eigen tabel die gebruikers niet mogen wijzigen.
  3. Alleen de tabellen testen die je kent. Een AI-builder maakt onderweg hulptabellen aan voor meldingen, logs of uitnodigingen. Test elke tabel die de Table Editor toont.
  4. Een view of functie die om je policies heen werkt. Een view draait standaard met de rechten van wie hem maakte, en een functie met security definer ook. Via /rest/v1/rpc/ kan een bezoeker zo’n functie aanroepen. Zet bij views alter view naam_van_view set (security_invoker = true); en geef functies alleen de rechten die ze nodig hebben. De Security Advisor markeert allebei.

Wanneer je hulp nodig hebt

Vind je een tabel die openstaat en weet je niet welke policies je app nodig heeft, of zit de logica in functies en views die je niet kunt lezen, laat het dan nakijken. In de securitycheck doen we deze tests voor elke tabel en elke rol, en zetten we dicht wat openstaat. Moet de app daarna ook rollen, betalingen of een beheerscherm krijgen, dan hoort dat bij je AI-app laten afmaken.

Stonden er persoonsgegevens open, dan kan dat een datalek zijn; wanneer je dat moet melden, lees je bij de Autoriteit Persoonsgegevens. Staat je project in een Amerikaanse regio, lees dan je app naar Europese hosting en wat AI-tools niet voor je regelen. Andere fouten uit AI-builders staan in je website is met AI gebouwd, de overige gidsen bij AI-apps afmaken en beveiligen.

Veelgestelde vragen⁠

Mijn verzoek geeft een lege lijst terug. Is de tabel dan veilig?

Alleen als er rijen in de tabel staan. Dan betekent een lege lijst dat je policies die rijen voor deze rol verbergen. Op een lege tabel zegt het niets: maak eerst met een testaccount een rij aan en test daarna opnieuw.

Kan ik dit testen zonder mijn live gegevens te raken?

Ja. De leesverzoeken veranderen niets. Wijzigen test je alleen op rijen van je eigen testaccounts, of in een databaseclient tussen begin en rollback, zodat niets blijft staan.

Hoe vaak moet ik dit doen?

Na elke wijziging aan tabellen, policies of rollen. Een AI-builder kan bij een nieuwe functie een policy aanpassen zonder dat je het merkt. De query naar te ruime policies kost een minuut; draai hem na elke grote prompt.

Verder lezen⁠

Een app die je met Lovable, Bolt, v0, Cursor of Claude bouwde: dichtzetten, afmaken en online houden.

Wij testen je database als buitenstaander⁠

De kennismaking is gratis. We reageren binnen één werkdag.