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:
| Role | Keterangan |
|---|---|
anon | User belum login |
authenticated | User 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”
anonrole → 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 JWTis_anonymous = true.
Inti Keseluruhan
| Langkah | Penjelasan |
|---|---|
| 1️⃣ Buat tabel | lewat SQL atau Table Editor |
| 2️⃣ Aktifkan RLS | alter table ... enable row level security; |
| 3️⃣ Tulis policy | sesuai 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 loginwith check→ hanya boleh INSERT kalauuser_idyang dikirim =auth.uid()milik user tersebut
3. Policy untuk UPDATE (mengubah data)
Policy UPDATE butuh dua klausa:
USING→ menentukan siapa yang boleh mengubah baris tertentuWITH 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:
usingmemastikan user hanya bisa mengubah baris yang dia milikiwith checkmemastikan 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
anondanauthenticated - 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 padacreated_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 definerdi schema yang diekspos oleh API (hindaripublicjika public diekspos). - Batasi
search_pathdan 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.