Záchrana · 9 min čtení
Checklist Supabase Row Level Security pro aplikace postavené s AI (s SQL)
Pokud vaše aplikace komunikuje se Supabase přímo z prohlížeče, je Row Level Security vaším řízením přístupu. Zapněte RLS na každé vystavené tabulce, pro každou operaci napište jednu policy s auth.uid(), klíč service_role držte na serveru, zabezpečte storage buckety a testujte jako anonymní uživatel i jako druhý účet. SQL ke zkopírování najdete níže.
Autor BacklooPublikováno
Hlavní body
- Anon (publishable) klíč je veřejný záměrně. Mezi ním a vašimi daty stojí jen RLS.
- Klíč `service_role` (secret) RLS úplně obchází a nikdy se nesmí dostat do prohlížeče ani do mobilní aplikace.
- Zapněte RLS na každé tabulce ve vystaveném schématu a pak napište explicitní policy pro select, insert, update a delete.
- Role patří do `app_metadata`, ne do `user_metadata`; pohledy (views), funkce se security definer a storage potřebují vlastní kontroly.
- Policy testujte tak, že se vydáváte za `anon` a za druhého uživatele — ne proklikáváním aplikace pod sebou.
Proč je Row Level Security v aplikacích postavených s AI tak důležité?
Protože nástroje jako Lovable a Bolt generují aplikace, v nichž se prohlížeč dotazuje Supabase napřímo. Mezi nimi není žádný backend, který by rozhodoval, kdo smí vidět co. Požadavek nese veřejný klíč a u přihlášeného uživatele i jeho JWT. Postgres pak uplatní policy Row Level Security. Pokud je RLS vypnuté nebo policy říká true, tabulku si může přečíst nebo změnit kdokoli, kdo má veřejný klíč — a ten je ve vašem JavaScriptu.
To se už stalo ve velkém. Chybějící RLS odhalilo data ve více než 170 aplikacích postavených v Lovable; zranitelnost je evidovaná jako CVE-2025-48757.
170+
Dobrá zpráva: RLS je obyčejné SQL, snadno se audituje a jeho oprava jen zřídka vyžaduje zásah do frontendu. Projděte sekce níže v uvedeném pořadí.
Anon klíč vs. klíč service_role: který patří kam?
Anon klíč (v novějších projektech publishable klíč) patří do klienta; klíč service_role (secret klíč) zůstává na serveru. Anon klíč odpovídá roli anon v Postgresu, po přihlášení uživatele roli authenticated, a na obě se RLS vztahuje. Klíč service_role odpovídá roli, která RLS úplně obchází.
| Klíč | Role v Postgresu | Platí RLS? | Kde smí být |
|---|---|---|---|
| anon / publishable | anon, poté authenticated | Ano | Prohlížeč, mobilní aplikace, veřejný repozitář |
| service_role / secret | service_role | Ne — obchází RLS | Jen server, edge funkce, tajné proměnné v CI |
Častá zkratka od AI: dotaz selže kvůli RLS a „oprava“ v klientovi vymění klíč za service role. Chyba zmizí — a s ní i celé vaše řízení přístupu. Prohledejte repozitář i produkční bundle, jestli neobsahují klíč service role nebo proměnnou pojmenovanou jako SERVICE_ROLE. Pokud se kdy dostal do prohlížeče, zrotujte ho.
Serverový kód, který jedná za uživatele
Edge Functions a API routy secret klíč automaticky nepotřebují. Když funkce jedná jménem přihlášeného uživatele, vytvořte klienta s veřejným klíčem a předejte dál hlavičku Authorization uživatele. Postgres pak vidí JWT uživatele a uplatní RLS přesně tak, jako by šlo o požadavek z prohlížeče. Secret klíč si nechte pro práci, za kterou opravdu nestojí žádný uživatel, například webhooky a plánované úlohy.
// Supabase Edge Function: dotaz jménem volajícího, takže RLS stále platí
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
);
const { data, error } = await supabase.from("projects").select("id, name");
if (error) return new Response(error.message, { status: 400 });
return Response.json(data);
});Krok 1: zapněte RLS na každé vystavené tabulce
Každá tabulka ve schématu vystaveném přes Data API (standardně public) musí mít zapnuté RLS. Tabulky vytvořené v editoru tabulek v administraci ho mají automaticky; tabulky vytvořené čistým SQL nebo migracemi — a tak je vytváří většina AI nástrojů — ho nemají, pokud to SQL výslovně neuvádí. Začněte výpisem mezer:
-- Tabulky ve schématu public s vypnutým RLS
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
-- Tabulky se zapnutým RLS, ale bez jediné policy (vše je zakázané — často znamení,
-- že to někdo později „opravil“ klíčem service_role)
select c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and c.relrowsecurity
and not exists (select 1 from pg_policies p
where p.schemaname = 'public' and p.tablename = c.relname);alter table public.projects enable row level security;Se zapnutým RLS a bez policy tabulka rolím anon a authenticated zakazuje všechno. To je bezpečný výchozí stav; teď ji vědomě otevřete. Tabulky bez RLS označuje také Security Advisor v administraci Supabase — spusťte ho, ale čistý report neberte jako důkaz, že jsou vaše policy správné.
Krok 2: napište jednu policy pro každou operaci
Místo jedné policy for all napište samostatné policy pro select, insert, update a delete, každou omezenou na konkrétní roli. Samostatné policy se snáz revidují a nutí vás rozhodnout, jestli uživatel vůbec smí řádky mazat. using filtruje, které existující řádky uživatel vidí nebo může měnit; with check validuje nový zapisovaný řádek.
-- Předpokládá sloupec vlastníka, který má jako výchozí hodnotu přihlášeného uživatele:
-- owner_id uuid not null default auth.uid() references auth.users (id) on delete cascade
create index if not exists projects_owner_id_idx on public.projects (owner_id);
create policy "projects: owners can read"
on public.projects for select
to authenticated
using ( (select auth.uid()) = owner_id );
create policy "projects: owners can create"
on public.projects for insert
to authenticated
with check ( (select auth.uid()) = owner_id );
create policy "projects: owners can update"
on public.projects for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );
create policy "projects: owners can delete"
on public.projects for delete
to authenticated
using ( (select auth.uid()) = owner_id );Dva detaily stojí za převzetí. to authenticated znamená, že se policy u anonymních požadavků vůbec nevyhodnocuje. A obalení auth.uid() do (select ...) umožní Postgresu vyhodnotit ho jednou za dotaz místo jednou za řádek, což je u velkých tabulek znát. Indexujte každý sloupec, podle kterého vaše policy filtrují.
-- Uživatelé smí ve svém profilu měnit jen konkrétní sloupce
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;Vzory s auth.uid(): vlastnictví, týmy a role
Téměř každá policy má jeden ze tří tvarů: řádek patří uživateli, řádek patří týmu, jehož je uživatel členem, nebo má uživatel určitou roli. Vlastnictví ukazuje příklad s owner_id výše. Týmy a role vyžadují o něco víc péče.
Členství v týmu
Kontrolu členství dejte do funkce security definer ve schématu, které není vystavené přes API. Vyhnete se tak rekurzivním policy na tabulce členství a vyhledávání zůstane rychlé.
create schema if not exists private;
grant usage on schema private to authenticated;
create or replace function private.is_team_member(p_team_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1
from public.team_members m
where m.team_id = p_team_id
and m.user_id = (select auth.uid())
);
$$;
revoke execute on function private.is_team_member(uuid) from public, anon;
grant execute on function private.is_team_member(uuid) to authenticated;
create policy "documents: team members can read"
on public.documents for select
to authenticated
using ( (select private.is_team_member(team_id)) );Role
Role čtěte z app_metadata, které může nastavit jen server. Častou chybou kódu od AI je kontrola user_metadata — přihlášení uživatelé si ale svá uživatelská metadata mohou upravit přímo z klienta, takže se mohou udělat administrátorem.
-- Bezpečné: app_metadata se nastavuje jen na serveru
create policy "invoices: admins can read all"
on public.invoices for select
to authenticated
using ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );
-- Nebezpečné: user_metadata může uživatel přepsat
-- using ( (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' )Kdy policy není správný nástroj
Na otázku „smí tento uživatel sáhnout na tento řádek?“ odpovídá RLS dobře. Na otázku „smí tento uživatel ve free tarifu zveřejnit čtvrtý projekt a má se při tom odeslat e-mail?“ odpovídá špatně. Když pravidlo závisí na kvótách, stavu fakturace nebo na tom, že se mění několik tabulek najednou, nechte tabulku zamčenou a vystavte serverový endpoint nebo úzce vymezenou funkci v Postgresu, která pravidlo zkontroluje a zápis provede. Policy, které se snaží zakódovat celý byznysový proces, jsou pomalé, špatně se testují a snadno v nich vznikne nenápadná chyba.
I storage buckety potřebují policy
Supabase Storage ukládá metadata souborů do storage.objects a přístup řídí policy RLS na této tabulce. Veřejný bucket vydá jakýkoli soubor komukoli, kdo zná nebo uhodne URL, takže veřejné buckety používejte jen pro skutečně veřejné soubory. Pro soubory uživatelů použijte privátní bucket, konvenci složky pro každého uživatele a pro stahování podepsané URL.
-- Soubory jsou uložené v documents/<id-uživatele>/<název-souboru>
create policy "documents bucket: owners can read"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "documents bucket: owners can upload"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "documents bucket: owners can delete"
on storage.objects for delete
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);U každého bucketu také nastavte seznam povolených MIME typů a limit velikosti souboru. Bez nich lze formulář pro nahrání avataru zneužít k hostování libovolných souborů na vaší doméně.
Časté chyby v RLS v kódu vygenerovaném AI
Za většinou z nich stojí stejný vzorec: AI narazila na chybu oprávnění a místo aby ji opravila správně, nechala ji zmizet. Když revidujete projekt v Supabase postavený s AI, hledejte nejdřív tyto.
- `using (true)` nebo `with check (true)` na tabulkách s uživatelskými daty. Někdy přidané „dočasně“, aby se odblokovala nějaká funkce.
- `to public` nebo žádná role u policy pro zápis, takže anonymní návštěvníci mohou vkládat nebo upravovat.
- Policy „pro každého přihlášeného“ jako
auth.role() = 'authenticated'na privátních datech: každý zákazník si přečte řádky všech ostatních zákazníků. - Policy pro insert bez `with check` na vlastnictví, takže uživatelé mohou vytvářet řádky patřící někomu jinému.
- Pohledy (views), které obcházejí RLS. Pohledy standardně běží s právy vlastníka. Na Postgresu 15+ je vytvářejte
with (security_invoker = true), aby platily policy volajícího. - Funkce `security definer` ve schématu `public`. Jsou volatelné přes API jako RPC a běží se zvýšenými právy. Držte je v privátním schématu nebo v nich kontrolujte
auth.uid(). - Klíč service role v klientském kódu, aby se obešla chyba policy.
- Role čtené z `user_metadata` místo z
app_metadata.
Jak testovat policy RLS
Policy testujte tak, že se vydáváte za role, které na ně skutečně narazí: anon, uživatel A a uživatel B. Proklikání aplikace pod sebou dokazuje jen to, že vy vidíte svá data. V SQL editoru můžete přepnout roli a podvrhnout JWT uvnitř transakce, kterou pak vrátíte zpět:
begin;
-- jednáme jako anonymní návštěvník
set local role anon;
select count(*) from public.projects; -- očekáváme 0
-- jednáme jako uživatel A
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"00000000-0000-0000-0000-00000000000a","role":"authenticated"}', true);
select count(*) from public.projects; -- očekáváme jen řádky uživatele A
update public.projects set name = 'changed'
where owner_id = '00000000-0000-0000-0000-00000000000b'; -- očekáváme 0 upravených řádků
rollback;Potom testujte zvenku, tak jako útočník, jen s veřejným klíčem z vašeho bundlu:
curl "https://<project-ref>.supabase.co/rest/v1/projects?select=*" \
-H "apikey: <anon-or-publishable-key>"
# Správně chráněná tabulka vrátí []Jakmile jsou policy v pořádku, zafixujte je automatickými testy. Supabase CLI spouští testy pgTAP příkazem supabase test db; hrstka asercí na tabulku (anon nevidí nic, A nepřečte data B, A nemůže převést vlastnictví) zachytí další úpravu od AI, která nějakou policy uvolní.
- RLS zapnuté na každé tabulce v každém vystaveném schématu.
- Žádný klíč
service_role/ secret v klientském kódu, bundlech ani veřejných repozitářích. - Samostatné policy pro každou operaci, každá s explicitní rolí
to. - Policy pro insert a update používají
with checkna vlastnictví. - Privilegované sloupce nejsou pro uživatele zapisovatelné.
- Role se berou z
app_metadata. - Pohledy používají
security_invoker; funkce s definer právy jsou v privátním schématu. - Privátní buckety s policy omezenými na složku, limity MIME typů a velikosti.
- Policy otestované jako anon, uživatel A a uživatel B — a pokryté testy pgTAP.
Jak vám Backloo může pomoct
Všechno výše zvládnete interně s SQL z této stránky. Pokud chcete druhý pár očí, policy pro RLS a storage jsou klíčovou součástí auditu v rámci naší záchrany AI aplikací: zmapujeme každou tabulku a bucket, otestujeme každou policy jako anon i jako konkurenční uživatelé a předáme vám migrace a testy pgTAP, které vlastníte. U aplikací, které z dotazů přímo z prohlížeče vyrostly, přesuneme citlivou logiku na server v rámci backendových systémů. Zbytek seznamu před spuštěním — webhooky, proměnné prostředí, monitoring — najdete v článku proč aplikace postavené s AI padají v produkci.
Otázky
Je anon klíč Supabase tajný?
Ne. Anon (publishable) klíč je určený k tomu, aby byl ve vašem frontendu. Bezpečný je ale jen tehdy, když je na každé vystavené tabulce zapnuté Row Level Security se správnými policy, protože právě RLS omezuje, co s tímto klíčem jde dělat.
Zapíná Lovable Row Level Security automaticky?
Nepočítejte s tím. Vygenerované migrace RLS zapnout mohou, ale nemusí, a policy mohou být příliš benevolentní. Spusťte dotaz z tohoto článku, vypište si tabulky bez RLS a každou policy si zkontrolujte sami.
Co byla zranitelnost CVE-2025-48757?
Záznam o zranitelnosti aplikací postavených v Lovable, jejichž tabulky v Supabase neměly dostatečné Row Level Security. Data ve více než 170 aplikacích tak byla přístupná komukoli s veřejným klíčem.
Zpomalují policy RLS moje dotazy?
U velkých tabulek mohou, pokud jsou napsané naivně. Obalte auth.uid() do poddotazu, indexujte sloupce, podle kterých policy filtrují, a vyhledávání členství přesuňte do funkce security definer v privátním schématu.
Mám v Edge Functions používat klíč service_role?
Jen když funkce opravdu potřebuje RLS obejít, například při zpracování platebního webhooku. U požadavků jménem uživatele vytvořte klienta s JWT uživatele, aby RLS stále platilo.