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+

aplikací postavených v Lovable, jejichž data byla odhalená kvůli chybějícímu Row Level Security v Supabase (CVE-2025-48757) — Superblocks ↗

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í.

API klíče Supabase v kostce
KlíčRole v PostgresuPlatí RLS?Kde smí být
anon / publishableanon, poté authenticatedAnoProhlížeč, mobilní aplikace, veřejný repozitář
service_role / secretservice_roleNe — obchází RLSJen 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.

ts
// 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:

sql
-- 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);
sql
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.

sql
-- 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í.

sql
-- 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é.

sql
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.

sql
-- 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.

sql
-- 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:

sql
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:

bash
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 check na 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.

Čtěte dál

  • 01 — Záchrana · 9 min čtení

    Aplikace z Lovable nebo Bolt funguje v náhledu, ale v produkci se rozbije: příčiny a řešení

    Většina aplikací postavených s AI, které fungují v náhledu a v produkci selžou, padá ze stejné hrstky důvodů: proměnné prostředí zapečené při buildu, přesměrování přihlášení stále mířící na náhled, chybějící autorizace v databázi, webhooky, které nejsou idempotentní, CORS a žádný monitoring chyb. Každý z nich má konkrétní a ověřitelné řešení.

  • 02 — Byznys · 7 min čtení

    Záchrana, refaktoring, nebo přepis? Jak se rozhodnout, co s kódem postaveným s AI

    Zachraňujte, když produkt funguje a problémy jsou v zabezpečení, konfiguraci a chybějících pojistkách. Refaktorujte, když funguje, ale každá změna je pomalá a riskantní. Přepisujte, jen když je zásadně špatně datový model nebo platforma — a i pak po částech. Rozhoduje datový model, ne to, jak neuspořádaně kód vypadá.

  • 03 — Engineering · 7 min čtení

    Jak splácet technický dluh, aniž byste zastavili vývoj funkcí

    Technický dluh splácíte bez zastavení vývoje funkcí tak, že ho berete jako stálou položku, ne jako projekt. Změřte, kde dluh skutečně bolí, veďte seřazený registr, v každém sprintu vyhraďte pevný podíl kapacity a riskantní části nahrazujte postupně za testy, abstrakcemi a feature flagy. Jestli to funguje, vám řeknou metriky DORA.

100 — INSIGHTS

Zaseknutí mezi demem a spuštěním?

Pošlete nám kontext. Do 24 hodin dostanete odpověď s plánem — nebo upřímné ne.