Web razvoj
SupabaseNext.jsRLSAutorizacijaMulti-tenantSigurnostApp Router

Supabase RLS + Next.js App Router: End-to-kraj autorizacija bez opasnih zamki

AO
Adrijan Omićević
·16 min čitanja

# Što ćeš izgraditi i zašto je to važno#

Ovaj vodič prikazuje siguran, end-to-end model autorizacije koristeći Supabase Row Level Security i Next.js App Router. Cilj je jednostavan: bez obzira što korisnik radi u pregledniku, može čitati i pisati samo one retke kojima smije pristupiti.

“Supabase RLS s Next.js” najbolje radi kada bazu tretiraš kao točku provedbe (enforcement), a Next.js kao sloj orkestracije. Ako podatke filtriraš samo u React komponentama ili API rutama, korisnik te može zaobići pozivajući Supabase direktno s istom sesijom.

Za dublje multi-tenant detalje i dodatne primjere, pročitaj i:

# Model prijetnji: opasne zamke protiv kojih moraš dizajnirati#

Bugovi u autorizaciji su skupi. IBM-ovo izvješće Cost of a Data Breach konzistentno pokazuje prosječne troškove proboja u milijunima USD, a broken access control i dalje je među najvišim OWASP kategorijama rizika. U multi-tenant SaaS-u, jedna propuštena tenant provjera može prerasti u izlaganje podataka između tenantova.

U kombinaciji Supabase + Next.js, većina incidenata dolazi iz nekoliko predvidljivih izvora:

  • Filteri na klijentu koji skrivaju podatke u UI-u, ali ne sprječavaju upite.
  • Curenje service role ključa koje trenutno zaobilazi RLS.
  • Nesiguran RPC gdje funkcija koristi SECURITY DEFINER ili zaboravi tenant provjere.
  • “Prečaci” na serveru gdje developeri koriste admin klijente radi praktičnosti i zaborave ponovno provjeriti autorizaciju.

🎯 Ključna poruka: Ako se do baze može doći s korisničkom sesijom, baza mora provoditi autorizaciju. RLS je granica provedbe.

# Preduvjeti#

ZahtjevVerzijaNapomene
Next.js14 ili 15Pretpostavlja se App Router
Supabase projektBilo koji aktualniPostgres s RLS-om
AuthSupabase AuthEmail, OAuth ili SSO
Znanje PostgresaOsnovnoTablice, strani ključevi, politike

# Sigurna osnovna arhitektura#

U praksi tvoja aplikacija ima tri “uloge”:

KontekstSupabase ključMože zaobići RLSTipična upotreba
Klijent u preglednikuanonNeKorisnik čita i piše pod RLS-om
Server u korisničkom kontekstuanon + korisnička sesijaNeServer Components i Route Handlers koji djeluju kao korisnik
Server u admin kontekstuservice roleDaPozadinski poslovi, webhookovi, support alati

Najsigurniji default je: nikad nemoj koristiti service role u putanji zahtjeva koja potječe od korisničkog klika. Ako baš moraš, tretiraj to kao pisanje vlastitog authorization sloja i računaj na greške.

⚠️ Upozorenje: Service role ključ u NEXT_PUBLIC_*, client bundleovima, logovima ili error telemetryju je praktički potpuni kompromis baze. Odmah ga rotiraj ako je izložen.

# Model podataka za multi-tenant aplikaciju#

Pouzdan multi-tenant model izbjegava “magične” claimove i sve drži eksplicitno u tablicama. Koristi:

  • tenants kao top-level workspace ili organizaciju.
  • tenant_members za povezivanje korisnika s tenantima i ulogama.
  • Tenant-scoped tablice koje sadrže tenant_id.

Primjer sheme#

TablicaSvrhaKljučni stupci
tenantsWorkspace spremnikid, name, created_at
tenant_membersČlanstvo i ulogatenant_id, user_id, role
projectsResurs vezan uz tenantid, tenant_id, name
tasksDijete od projectsid, tenant_id, project_id, title

Koristi tenant_id na svakoj tenant-scoped tablici čak i ako ga možeš izvesti preko project_id. To pojednostavljuje politike, poboljšava performanse i smanjuje broj bugova u politikama.

SQL: shema i ograničenja#

SQL
-- tenants
create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  created_at timestamptz not null default now()
);
 
-- membership
create table public.tenant_members (
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null check (role in ('owner','admin','member')),
  created_at timestamptz not null default now(),
  primary key (tenant_id, user_id)
);
 
-- projects
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);
 
-- tasks
create table public.tasks (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  project_id uuid not null references public.projects(id) on delete cascade,
  title text not null,
  created_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);
 
create index on public.tenant_members (user_id, tenant_id);
create index on public.projects (tenant_id);
create index on public.tasks (tenant_id, project_id);

# Uključi RLS i definiraj provjeru članstva koja se može ponovno koristiti#

Uključi RLS za svaku tenant-scoped tablicu, zatim centraliziraj logiku članstva. U Postgresu je najčišći obrazac mala stable SQL funkcija koja provjerava članstvo koristeći auth.uid().

SQL: uključi RLS#

SQL
alter table public.tenants enable row level security;
alter table public.tenant_members enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;

SQL: pomoćna funkcija za tenant pristup#

SQL
create or replace function public.is_tenant_member(p_tenant_id uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1
    from public.tenant_members tm
    where tm.tenant_id = p_tenant_id
      and tm.user_id = auth.uid()
  );
$$;

Ova funkcija se izvršava u kontekstu pozivatelja i i dalje poštuje RLS na tenant_members osim ako ga eksplicitno ne zaobiđeš. To je dobro, ali znači da moraš napisati ispravne politike i za tenant_members.

ℹ️ Napomena: Ako provjere članstva djeluju “kružno”, to obično znači da politika na tablici tenant_members nedostaje ili je previše restriktivna. Kreni od toga da membership politike rade kako treba, pa onda gradi dalje.

# Dizajn politika: najmanje privilegije po operaciji#

Dizajniraj politike po operaciji, ne po tablici. Želiš odvojene politike za select, insert, update i delete. Time se smanjuje rizik da slučajno omogućiš write kada si namjeravao samo read.

Politike za tenant_members#

Tipična pravila:

  • Korisnik može vidjeti vlastite membership retke.
  • Owneri i admini mogu upravljati članstvima unutar svog tenanta.
  • Samo owneri mogu obrisati drugog ownera.

Evo pragmatične baze za početak.

SQL
-- read your own memberships
create policy "tenant_members_read_own"
on public.tenant_members
for select
using (user_id = auth.uid());
 
-- owners and admins can read all members in their tenant
create policy "tenant_members_read_tenant"
on public.tenant_members
for select
using (
  public.is_tenant_member(tenant_id)
);
 
-- only owners can insert members, and only into their tenant
create or replace function public.is_tenant_owner(p_tenant_id uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1 from public.tenant_members tm
    where tm.tenant_id = p_tenant_id
      and tm.user_id = auth.uid()
      and tm.role = 'owner'
  );
$$;
 
create policy "tenant_members_insert_owner_only"
on public.tenant_members
for insert
with check (
  public.is_tenant_owner(tenant_id)
);
 
-- only owners can update roles, inside their tenant
create policy "tenant_members_update_owner_only"
on public.tenant_members
for update
using (public.is_tenant_owner(tenant_id))
with check (public.is_tenant_owner(tenant_id));

Ovo nije kompletan “enterprise” set politika, ali je dovoljno siguran da na njemu iteriraš, i prisiljava te da budeš eksplicitan oko toga tko smije što.

Politike za projects#

Pravila:

  • Svaki član tenanta može čitati projekte u svom tenantu.
  • Svaki član tenanta može kreirati projekt u svom tenantu.
  • Samo članovi mogu raditi update, po želji ograniči update na admine.
  • Brisanje je tipično ograničeno na admine ili ownere.
SQL
create policy "projects_select_member"
on public.projects
for select
using (public.is_tenant_member(tenant_id));
 
create policy "projects_insert_member"
on public.projects
for insert
with check (
  public.is_tenant_member(tenant_id)
  and created_by = auth.uid()
);
 
create policy "projects_update_member"
on public.projects
for update
using (public.is_tenant_member(tenant_id))
with check (public.is_tenant_member(tenant_id));

Politike za tasks uz konzistentnost s roditeljem#

Čest bug je dopuštanje da se tenant_id arbitrarnno postavi na child retku. Uvijek osiguraj da child red pripada istom tenantu kao i njegov parent.

SQL
create or replace function public.project_belongs_to_tenant(p_project_id uuid, p_tenant_id uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1 from public.projects p
    where p.id = p_project_id
      and p.tenant_id = p_tenant_id
  );
$$;
 
create policy "tasks_select_member"
on public.tasks
for select
using (public.is_tenant_member(tenant_id));
 
create policy "tasks_insert_member_with_parent_check"
on public.tasks
for insert
with check (
  public.is_tenant_member(tenant_id)
  and created_by = auth.uid()
  and public.project_belongs_to_tenant(project_id, tenant_id)
);

# Next.js App Router: obrasci provedbe na serveru#

RLS već provodi pristup na sloju podataka. Posao App Routera je osigurati:

  • da slučajno ne zaobiđeš RLS koristeći service role
  • da čitanja i pisanja strukturiraš tako da se izvršavaju pod korisničkom sesijom
  • da ne curiš tenant identifikatore niti vjeruješ tenant inputu koji dolazi od korisnika

Obrazac 1: Server Components za čitanja, Route Handlers za pisanja#

Čitanja često dobro sjedaju u Server Components jer se izvršavaju na serveru i mogu koristiti korisničku sesiju temeljenu na cookiesima. Pisanja bi u pravilu trebala ići kroz Route Handlers gdje možeš validirati input i vraćati jasne greške.

Koristi jedan mehanizam odabira tenanta, tipično:

  • active_tenant_id spremljen u tablici profiles pod RLS-om, ili
  • tenant slug u URL-u, a članstvo se provodi RLS-om.

Ako koristiš URL slugove, tretiraj ih kao routing identifikatore, ne kao authorization provjere. Autorizacija se i dalje događa u RLS-u.

Obrazac 2: Uvijek kreiraj “user-scoped” Supabase klijent na serveru#

U App Routeru želiš server klijent koji čita Supabase sesiju iz cookiesa i koristi anon ključ. To osigurava da se RLS primjenjuje.

TypeScript
// lib/supabase/server.ts
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
 
export function createSupabaseServerClient() {
  const cookieStore = cookies();
 
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach((c) => cookieStore.set(c.name, c.value, c.options));
        },
      },
    }
  );
}

Ovo je namjerno bazirano na anon ključu. Sesija ga čini da “djeluje kao korisnik”, i RLS se provodi.

💡 Savjet: Napravi i drugi helper koji zahtijeva korisničku sesiju i baca grešku ako korisnik nije prijavljen. Uklonit ćeš puno rubnih slučajeva “optional auth” i smanjiti broj neautoriziranih stanja.

TypeScript
// lib/auth/require-user.ts
import { createSupabaseServerClient } from "@/lib/supabase/server";
 
export async function requireUser() {
  const supabase = createSupabaseServerClient();
  const { data, error } = await supabase.auth.getUser();
 
  if (error || !data.user) throw new Error("Unauthorized");
  return { supabase, user: data.user };
}

Obrazac 3: Nemoj prihvaćati tenant_id od klijenta osim ako ga validiraš#

Klasičan multi-tenant bug je API ruta koja prihvaća tenant_id i s njim umeće retke. Insert može proći ako su politike previše permisivne ili ako kod slučajno koristi service role.

Umjesto toga:

  • izvedi tenant_id iz URL-a i pusti RLS da blokira nečlanove, ili
  • dohvatite tenantove kojima korisnik smije pristupiti i provjeri članstvo prije nastavka.

Evo sigurnog obrasca za Route Handler koji čita tenant slug iz paramsa, resolvea ga, pa radi insert.

TypeScript
// app/api/tenants/[tenantId]/projects/route.ts
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth/require-user";
 
export async function POST(req: Request, ctx: { params: Promise<{ tenantId: string }> }) {
  const { supabase, user } = await requireUser();
  const { tenantId } = await ctx.params;
 
  const body = await req.json();
  const name = String(body?.name ?? "").trim();
 
  if (name.length < 2) return NextResponse.json({ error: "Invalid name" }, { status: 400 });
 
  const { data, error } = await supabase
    .from("projects")
    .insert({ tenant_id: tenantId, name, created_by: user.id })
    .select("id, tenant_id, name")
    .single();
 
  if (error) return NextResponse.json({ error: error.message }, { status: 403 });
  return NextResponse.json({ project: data });
}

Ovo i dalje prima tenantId iz URL-a, ali mu nikad ne vjeruje za autorizaciju. Ako korisnik nije član, RLS odbija insert.

Obrazac 4: Izbjegavaj service-role u Route Handlerima#

Ako koristiš service role u handleru iznad, insert će uvijek proći, čak i za nečlanove, osim ako ručno implementiraš provjere. To je najčešća “radilo je u devu” regresija autorizacije koju viđamo u auditima.

Ako ti trebaju admin-level operacije:

  • izoliraj ih u zasebne endpointove
  • zaštiti admin autentikacijom
  • implementiraj eksplicitne provjere
  • logiraj svaki poziv
  • razmisli o prebacivanju u background jobs

# RPC i “nesigurne funkcije”: zona visokog rizika#

RPC je koristan za kompleksne operacije, ali je i mjesto gdje timovi slučajno isključe RLS.

Evo sigurnog baselinea:

RPC izborIzvršava se kaoRLS se primjenjujeRizik
Default funkcijainvokerDaNizak, ako postoje tenant provjere
SECURITY DEFINERdefinerNe, osim ako se prisiliVisok
Definer + ručne provjeredefinerNeSrednji, lako je propustiti provjere

Ako koristiš RPC za write preko više tablica, preferiraj:

  • invoker mode
  • eksplicitne provjere tenant članstva unutar funkcije
  • minimalnu površinu (surface area)

Primjer: relativno siguran invoker RPC#

SQL
create or replace function public.create_task(p_tenant_id uuid, p_project_id uuid, p_title text)
returns public.tasks
language plpgsql
as $$
declare
  v_task public.tasks;
begin
  if not public.is_tenant_member(p_tenant_id) then
    raise exception 'not a tenant member';
  end if;
 
  if not public.project_belongs_to_tenant(p_project_id, p_tenant_id) then
    raise exception 'project not in tenant';
  end if;
 
  insert into public.tasks (tenant_id, project_id, title, created_by)
  values (p_tenant_id, p_project_id, p_title, auth.uid())
  returning * into v_task;
 
  return v_task;
end;
$$;

Ovo se i dalje oslanja na RLS za čitanja tablica koja se rade drugdje, ali forsira tenant provjere odmah na početku.

⚠️ Upozorenje: Izbjegavaj SECURITY DEFINER osim ako imaš jak razlog i temeljit proces reviewa. Mnoga produkcijska curenja nastanu jer definer funkcija dopušta cross-tenant čitanja bez uključivanja politika.

# End-to-End tok: sigurno multi-tenant čitanje i pisanje#

Dobar “E2E authorization” test je: može li korisnik s valjanom sesijom čitati podatke drugog tenanta mijenjanjem identifikatora.

Read tok u Server Componentu#

  • Komponenta dobiva tenantId iz route paramsa.
  • Queryja projects po tenant_id.
  • RLS osigurava da su vidljivi samo retci za članove.
TypeScript
// app/(app)/tenants/[tenantId]/projects/page.tsx
import { createSupabaseServerClient } from "@/lib/supabase/server";
 
export default async function ProjectsPage(props: { params: Promise<{ tenantId: string }> }) {
  const { tenantId } = await props.params;
  const supabase = createSupabaseServerClient();
 
  const { data: projects, error } = await supabase
    .from("projects")
    .select("id, name, created_at")
    .eq("tenant_id", tenantId)
    .order("created_at", { ascending: false });
 
  if (error) throw new Error("Not authorized or query failed");
 
  return (
    <main>
      <h1>Projects</h1>
      <ul>
        {(projects ?? []).map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </main>
  );
}

Čak i ako korisnik promijeni URL na drugi tenant ID, upit vraća prazan skup ili authorization grešku, ovisno o tvojoj politici i ponašanju PostgREST-a.

Write tok u Route Handleru#

Insert primjer si već vidio. Ključna poanta je: server provodi autentikaciju i validaciju inputa, baza provodi autorizaciju.

# Učvršćivanje politika za stvarni SaaS#

Baseline politike iznad su ispravne, ali nisu kompletne. Evo čestih dodataka koji sprječavaju suptilne multi-tenant bugove.

Dodaj immutable ograničenja za tenant_id#

Spriječi update koji premješta retke između tenantova. Čak i ako UI to nikad ne radi, napadači će pokušati.

SQL
create policy "projects_prevent_tenant_move"
on public.projects
for update
using (public.is_tenant_member(tenant_id))
with check (tenant_id = (select tenant_id from public.projects p where p.id = id));

Ovaj obrazac može biti nezgrapan. Mnogi timovi to rješavaju ovako:

  • ograničavanjem updatea na određene stupce koristeći viewove, ili
  • triggerima koji blokiraju promjene tenant_id

Osiguraj da child retci ne mogu “odlutati” između tenantova#

Već si dodao project_belongs_to_tenant. Primijeni ga i na update, ne samo na insert.

SQL
create policy "tasks_update_member_with_parent_check"
on public.tasks
for update
using (public.is_tenant_member(tenant_id))
with check (
  public.is_tenant_member(tenant_id)
  and public.project_belongs_to_tenant(project_id, tenant_id)
);

Izbjegni “policy sprawl” dosljednim imenovanjem i predlošcima#

U multi-tenant aplikacijama stvorit ćeš desetke politika. Koristi konvenciju imenovanja poput:

  • {table}_{op}_{who}_{constraints}
    Primjer: tasks_insert_member_parent_check

To ubrzava audite i PR review.

# Kontrolna lista: česte greške koje lome autorizaciju#

Koristi ovo kao gate prije releasea. Hvata većinu stvarnih problema koje viđamo u Supabase + Next.js projektima.

Curenje service role ključa i pogrešna upotreba#

  • Service role ključ nije ni u jednoj NEXT_PUBLIC_* env varijabli.
  • Service role ključ se ne koristi ni u jednom Server Componentu ili Route Handleru koji radi u ime krajnjeg korisnika.
  • Service role ključ se koristi samo u izoliranim admin jobovima, webhookovima ili pozadinskoj obradi.
  • Logovi i error trackeri ne hvataju environment varijable ili headere koji sadrže tajne.

Nesiguran RPC i definer funkcije#

  • Ne postoje SECURITY DEFINER funkcije bez dokumentiranog razloga i reviewa.
  • Svaki RPC koji prima tenant_id provjerava članstvo unutar funkcije.
  • RPC funkcije ne vraćaju cross-tenant agregate osim ako je to eksplicitno namijenjeno.
  • Politike se i dalje primjenjuju na tablice koje RPC dira, ili ih provjere u potpunosti zamjenjuju.

Filtriranje na klijentu i “autorizacija samo u UI-u”#

  • Nijedna stranica se ne oslanja na skrivanje UI elemenata kao jedinu autorizaciju.
  • Svaki query koji uključuje tenant identifikator je siguran čak i ako se identifikator promijeni.
  • Ne postoji “dohvati sve pa filtriraj u Reactu” za tenant-scoped podatke.
  • API rute validiraju inpute, ali ne pokušavaju ponovno implementirati logiku row autorizacije.

RLS pokrivenost i ispravnost politika#

  • RLS je uključen na svakoj tenant-scoped tablici.
  • Svaka tablica ima politike za select, insert, update i delete po potrebi.
  • Politike provjeravaju i članstvo i konzistentnost parent/child za child tablice.
  • Postoje indeksi koji podržavaju policy provjere, posebno na tenant_members(user_id, tenant_id).

# Testiranje: dokaži da je cross-tenant pristup nemoguć#

Nemoj isporučiti bez barem ovih testova:

  1. 1
    Korisnik A kreira tenant, projekt i task.
  2. 2
    Korisnik B je u drugom tenantu.
  3. 3
    Provjeri da Korisnik B ne može:
    • selektirati projekte Korisnika A mijenjanjem tenant_id
    • ubaciti task u projekt Korisnika A
    • uspješno pozvati RPC s tenant_id Korisnika A
  4. 4
    Provjeri očekivana admin ponašanja, npr. owner upravlja članovima.

Jednostavan automatizirani pristup je pokrenuti integracijske testove koji:

  • prijave se s dva testna korisnika
  • izvrše iste requestove s različitim cookiesima
  • asertaju prazne rezultate ili 403 greške

# Ključne poruke#

  • Tretiraj RLS kao granicu autorizacije, a Next.js kao orkestraciju, ne kao provedbu.
  • Koristi multi-tenant shemu s eksplicitnim tenant_id na svakoj tenant-scoped tablici i provodi članstvo preko tenant_members.
  • Koristi server-side Supabase klijente s anon ključem + korisničkom sesijom kako bi RLS ostao aktivan u Server Components i Route Handlers.
  • Izbjegavaj service role u user request putanjama, a admin operacije izoliraj iza eksplicitnih provjera i logiranja.
  • Budi iznimno oprezan s RPC-om, posebno sa svime što nalikuje SECURITY DEFINER, i validiraj tenant pristup unutar funkcija.
  • Koristi kontrolnu listu da uhvatiš tri velika kvara: curenje service role ključa, nesiguran RPC i filtere na klijentu.

# Zaključak#

Supabase RLS plus Next.js App Router je snažna kombinacija kada dosljedno izvršavaš upite pod korisničkom sesijom i prepuštaš bazi da provodi tenant granice. Najbrži put do sigurnog releasea je krenuti s čistom multi-tenant shemom, implementirati politike najmanjih privilegija po operaciji i zabraniti service-role prečace u endpointovima koje pokreću korisnici.

Ako želiš da pregledamo tvoje politike, učvrstimo tvoje App Router server obrasce ili dizajniramo multi-tenant authorization model koji skalira bez iznenađenja, kontaktiraj Samioda putem naše stranice za web development ili nastavi s našim vodičima o multi-tenant RLS-u, App Router authz obrascima i širom sigurnosnom checklistom.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.