Vito Solin
  • Portfolio
  • Blog

© 2025 Vito Solin. All rights reserved.

← Back to Blog
supabase

Belajar Row Level Security Supabase

October 19, 2025

Oke, halo teman-teman semuanya. Jadi hari ini kita akan belajar tentang RLS. Kita akan langsung ngulik RLS di dokumentasi resmi RLS Supabase

Let's go!

Apa Itu Row Level Security (RLS)

Row Level Security (RLS) adalah fitur keamanan di PostgreSQL yang memungkinkan kamu mengatur siapa yang bisa melihat atau mengubah baris tertentu di sebuah tabel. Kalau biasanya kita hanya bisa mengatur izin di level tabel (misalnya: user A bisa lihat tabel todos), RLS memungkinkan kita membatasi hingga level per baris (misalnya: user hanya bisa lihat todos miliknya sendiri).


RLS di Supabase

Supabase memungkinkan kita mengakses database langsung dari browser (frontend). Tapi supaya aman, RLS harus diaktifkan untuk setiap tabel yang bisa diakses publik (biasanya schema public).

Jika kamu buat tabel dari Table Editor di dashboard, RLS otomatis aktif. Tapi kalau kamu bikin tabel lewat SQL, kamu perlu aktifkan sendiri:

alter table public.nama_tabel enable row level security;

Kalau sudah diaktifkan, semua data akan tertutup total (tidak bisa diakses melalui API) sampai kamu menulis aturan policy-nya.


Cara Kerja RLS: Policy

Policy adalah aturan (rule) yang menentukan baris mana yang boleh dilihat, diubah, dihapus, atau ditambahkan oleh pengguna.

Kamu bisa anggap policy itu seperti menambahkan kondisi WHERE otomatis pada setiap query.

Contoh:

create policy "User hanya bisa lihat todos miliknya"
on todos for select
using (auth.uid() = user_id);

Artinya: setiap kali user mengambil data todos, Supabase akan otomatis menambahkan:

select * from todos where auth.uid() = user_id;

Mengaktifkan RLS

Gunakan perintah berikut:

alter table "nama_tabel" enable row level security;

Setelah itu, tidak ada data yang bisa diakses sebelum kamu menulis policy-nya.


Catatan Penting: auth.uid() bisa NULL

Fungsi auth.uid() hanya punya nilai kalau user sudah login. Kalau belum login (tidak ada token), nilainya NULL.

Contoh policy seperti ini:

using (auth.uid() = user_id)

tidak akan pernah lolos untuk user yang belum login, karena NULL = user_id akan selalu FALSE di SQL.

Solusinya:

using (auth.uid() IS NOT NULL AND auth.uid() = user_id);

Dengan begitu, hanya user yang login dan cocok user_id-nya yang bisa mengakses.


Role di Supabase

Setiap request ke Supabase punya role:

RoleKeterangan
anonUser belum login
authenticatedUser sudah login

Kamu bisa menentukan role mana yang boleh menjalankan policy lewat TO:

-- Bisa diakses oleh semua user (login & belum login)
create policy "Lihat semua profil"
on profiles for select
to authenticated, anon
using (true);

-- Hanya user login yang boleh lihat
create policy "Hanya user login yang boleh lihat profil"
on profiles for select
to authenticated
using (true);

Beda “Anonymous User” vs “Anon Key”

  • anon role → bawaan dari Supabase/Postgres, digunakan saat request tanpa login.
  • Anonymous user (dari Supabase Auth) → sebenarnya login juga, hanya saja bersifat sementara (guest). Mereka tetap memakai role authenticated, tapi bisa dibedakan dengan klaim JWT is_anonymous = true.

Inti Keseluruhan

LangkahPenjelasan
1️⃣ Buat tabellewat SQL atau Table Editor
2️⃣ Aktifkan RLSalter table ... enable row level security;
3️⃣ Tulis policysesuai kebutuhan (select, insert, update, delete)
4️⃣ Gunakan auth.uid()untuk mengaitkan data dengan user login
5️⃣ Tentukan role (anon, authenticated)agar sesuai akses yang diinginkan

Kalimat singkat buat mengingat:

“RLS membuat setiap baris data punya ‘satpamnya’ sendiri, dan policy menentukan siapa yang boleh lewat.”


Apa Itu Policy?

Policy adalah aturan SQL yang kamu pasang di sebuah tabel PostgreSQL untuk mengontrol siapa boleh baca, tambah, ubah, atau hapus baris data tertentu. Kamu bisa punya banyak policy di satu tabel, dan setiap policy akan dijalankan setiap kali tabel itu diakses.

Supabase menyediakan helper functions seperti auth.uid() agar policy ini bisa langsung terhubung dengan sistem login-nya (Supabase Auth).


1. Policy untuk SELECT (membaca data)

Policy SELECT menggunakan klausa USING. Artinya, kalau kondisi USING terpenuhi, baris itu boleh dibaca.

Contoh 1 — Semua orang boleh lihat profil

-- 1. Buat tabel
create table profiles (
  id uuid primary key,
  user_id uuid references auth.users,
  avatar_url text
);

-- 2. Aktifkan RLS
alter table profiles enable row level security;

-- 3. Buat policy
create policy "Public profiles are visible to everyone."
on profiles for select
to anon
using (true);

Penjelasan:

  • to anon → boleh diakses oleh semua orang (termasuk yang belum login)
  • using (true) → artinya tidak ada batasan khusus, semua baris boleh dibaca

Contoh 2 — Hanya boleh lihat profil sendiri

create policy "User can see their own profile only."
on profiles
for select
using (auth.uid() = user_id);

Artinya hanya user yang auth.uid()-nya sama dengan user_id di tabel yang bisa membaca baris itu.


2. Policy untuk INSERT (menambah data)

Policy INSERT memakai klausa WITH CHECK. Kondisi ini memastikan data yang dimasukkan harus memenuhi aturan policy.

Contoh — User hanya boleh buat profil untuk dirinya sendiri

-- 1. Buat tabel
create table profiles (
  id uuid primary key,
  user_id uuid references auth.users,
  avatar_url text
);

-- 2. Aktifkan RLS
alter table profiles enable row level security;

-- 3. Buat policy
create policy "Users can create a profile."
on profiles for insert
to authenticated
with check (auth.uid() = user_id);

Penjelasan:

  • to authenticated → hanya user yang login
  • with check → hanya boleh INSERT kalau user_id yang dikirim = auth.uid() milik user tersebut

3. Policy untuk UPDATE (mengubah data)

Policy UPDATE butuh dua klausa:

  • USING → menentukan siapa yang boleh mengubah baris tertentu
  • WITH CHECK → memastikan hasil akhir data yang diubah tetap memenuhi aturan

Contoh — User hanya boleh ubah profil miliknya

create policy "Users can update their own profile."
on profiles for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

Penjelasan:

  • using memastikan user hanya bisa mengubah baris yang dia miliki
  • with check memastikan setelah diubah pun, user_id-nya tidak berubah ke milik orang lain (jadi user tidak bisa “mencuri” data profil lain)

Catatan penting: Agar UPDATE bisa dilakukan, kamu harus punya policy SELECT juga di tabel itu. Tanpa SELECT, UPDATE tidak akan berfungsi dengan benar karena sistem tidak bisa memastikan baris mana yang kamu ubah.


🔴 4. Policy untuk DELETE (menghapus data)

DELETE juga memakai klausa USING. Kalau kondisi USING terpenuhi, user boleh menghapus baris itu.

Contoh — User hanya boleh hapus profil sendiri

create policy "Users can delete a profile."
on profiles for delete
to authenticated
using (auth.uid() = user_id);

Artinya, user hanya bisa menghapus baris yang user_id-nya sama dengan dirinya.


5. Tentang VIEW dan RLS

Secara default, view di Postgres tidak mengikuti aturan RLS. Ini karena view biasanya dibuat oleh user postgres (superuser) yang otomatis punya hak akses penuh (security definer).

Kalau kamu ingin view tetap mengikuti policy RLS dari tabel aslinya, gunakan opsi security_invoker = true (tersedia mulai PostgreSQL 15):

create view my_view
with (security_invoker = true)
as select * from profiles;

Artinya, view akan “berperilaku” sesuai dengan role yang memanggilnya (anon atau authenticated).

Kalau kamu masih pakai versi Postgres lama, cara aman adalah:

  • Batasi akses ke view untuk role anon dan authenticated
  • Atau simpan view di schema yang tidak diekspos ke publik

RLS & Performa: Prinsip Utama

  • RLS itu seperti WHERE tersembunyi yang ditambah ke setiap query.
  • Dampaknya paling terasa saat kamu scan banyak baris (SELECT tanpa filter, ORDER BY, LIMIT/OFFSET).
  • Tujuan kita: kurangi kerja per-baris, ubah jadi sekali hitung per statement, dan bantu planner dengan filter & index yang tepat.

1) Tambahkan Index untuk Kolom yang Dipakai di Policy

Masalah: Policy sering membandingkan auth.uid() dengan kolom seperti user_id. Tanpa index, Postgres harus scan banyak baris.

Solusi:

-- contoh policy
create policy "rls_test_select" on test_table
to authenticated
using ( (select auth.uid()) = user_id );

-- index yang mendukung policy
create index idx_test_table_user_id on test_table using btree (user_id);

Intinya:

  • Index di kolom yang sering muncul di USING / WITH CHECK (mis. user_id, team_id, org_id, status)
  • Untuk filter gabungan (mis. org_id, user_id) pertimbangkan index multi-kolom.

Bonus: kalau sering ORDER BY created_at, tambahkan index pada created_at (atau gabung dengan kolom filter utama).


2) Wrap function dengan SELECT (cache per statement)

Masalah: Memanggil fungsi seperti auth.uid() / auth.jwt() per baris bikin lambat.

Solusi:

-- kurang efisien
using ( auth.uid() = user_id )

-- lebih efisien (cached per statement)
using ( (select auth.uid()) = user_id )

Kenapa cepat? Postgres bikin initPlan (sekali hitung) → hasil dipakai ulang, bukan dieksekusi di setiap baris.

Catatan: Pakai teknik ini hanya jika hasil fungsi tidak tergantung baris (benar untuk auth.uid()/auth.jwt()).


3) Selalu Tambahkan Filter di Query Client-side

Masalah: Mengandalkan policy saja → planner kurang info → plan bisa jelek.

Solusi (duplikasi filter di client):

// kurang bagus
const { data } = await supabase.from('table').select()

// lebih bagus
const { data } = await supabase
  .from('table')
  .select()
  .eq('user_id', userId)         // sinkron dengan policy
  .order('created_at', { ascending: false }) // bantu index
  .limit(50)

Walau “terkesan dobel” dengan policy, ini membantu Postgres memilih rencana eksekusi yang optimal.


4) Gunakan Security Definer Function untuk Cek Kompleks

Masalah: Policy yang melakukan join/subquery berat ke tabel lain bakal kena penalti RLS & row-by-row.

Solusi: Bungkus logika cek ke function SECURITY DEFINER (dibuat oleh role yang punya akses) lalu panggil sebagai nilai tunggal dalam policy:

-- contoh policy berat (langsung query roles_table)
create policy "rls_test_select" on test_table
to authenticated
using (
  exists (
    select 1
    from roles_table
    where (select auth.uid()) = user_id
      and role = 'good_role'
  )
);

-- ubah: pakai fungsi security definer
create function private.has_good_role()
returns boolean
language plpgsql
security definer
as $$
begin
  return exists (
    select 1 from roles_table
    where (select auth.uid()) = user_id and role = 'good_role'
  );
end;
$$;

create policy "rls_test_select" on test_table
to authenticated
using ( (select private.has_good_role()) );

Penting:

  • JANGAN taruh function security definer di schema yang diekspos oleh API (hindari public jika public diekspos).
  • Batasi search_path dan hak akses function untuk keamanan.

5) Minimalkan JOIN di Policy (ubah jadi set/array lookup)

Masalah: Join antara tabel sumber & tabel lain di dalam policy memaksa Postgres melakukan kerja berat per baris.

Solusi: Ambil daftar kriteria sebagai set/array, lalu cocokan dengan IN/ANY.

Kurang efisien (join implisit):

create policy "rls_test_select" on test_table
to authenticated
using (
  (select auth.uid()) in (
    select user_id
    from team_user
    where team_user.team_id = team_id  -- refer ke kolom test_table
  )
);

Lebih efisien (tanpa join ke baris sumber):

create policy "rls_test_select" on test_table
to authenticated
using (
  team_id in (
    select team_id
    from team_user
    where user_id = (select auth.uid())
  )
);

Jika daftar hasil bisa > 1000 item, uji lagi: mungkin perlu pendekatan lain (materialized view, cache tabel per user/org, atau fungsi security definer).


6) Selalu Spesifikasikan Role di Policy (TO ...)

Kenapa: Biar policy tidak dievaluasi untuk role yang tidak relevan.

-- kurang spesifik: akan dievaluasi juga untuk anon
create policy "rls_test_select" on rls_test
using ( (select auth.uid()) = user_id );

-- lebih baik: hanya untuk authenticated
create policy "rls_test_select" on rls_test
to authenticated
using ( (select auth.uid()) = user_id );

Hasilnya: untuk request anon, Postgres berhenti di tahap role-checking (lebih cepat).


Extra Tips (praktis banget)

✅ A. Simpan hasil fungsi sekali per query (CTE)

with me as (select (select auth.uid()) as uid)
select *
from test_table t
join me on true
where t.user_id = me.uid;

Pola ini di query kamu (bukan policy) juga mencegah pemanggilan berulang.

✅ B. Pilih jenis index yang tepat

  • btree untuk equality/range biasa (=, <, >, ORDER BY).
  • partial index kalau sebagian besar baris tak pernah diakses (mis. org_id = X).
  • covering index (include columns) untuk menghindari lookup (Postgres 11+ pakai INCLUDE).

✅ C. Hindari fungsi volatile di policy

Pakai fungsi yang immutable/stable untuk memberi planner peluang caching/optimisasi. (Memanggil now() di policy bukan ide bagus—taruh di query/kolom).

✅ D. Paginate dengan kriteria yang ter-index

OFFSET besar itu mahal. Lebih baik keyset pagination:

-- daripada OFFSET 10000
where created_at < :cursor_created_at
order by created_at desc
limit 50

✅ E. Uji dengan EXPLAIN (ANALYZE, BUFFERS)

Lihat apakah index dipakai, ada “Seq Scan” tak perlu, dan berapa biaya estimasi. Optimasi iteratif.

Rekomendasi Bacaan

Sebelumnya
Semua tentang Supabase
supabase • #1
Selanjutnya
RLS Supabase: Jangan select * ke Tabel Mentah. Pakai VIEW. Pakai Policy. Selesai.
supabase • #3