RLS Supabase: Jangan select * ke Tabel Mentah. Pakai VIEW. Pakai Policy. Selesai.
October 19, 2025
Banyak orang masih fetch profil begini:
from('profiles').select('*')Berbahaya. Kenapa? Karena walau kamu merasa aman, RLS hanya menjaga baris—bukan kolom. Tanpa disiplin VIEW & policy yang benar, kamu membuka pintu bocor metadata, role internal, atau field yang seharusnya privat.
Di balik Supabase ada PostgreSQL full power. Salah satu super power-nya: Row Level Security (RLS). Di tulisan ini kita bongkar tuntas:
- Apa itu RLS (dan cara mikirnya)
- Bedanya
USINGvsWITH CHECK(pakai kapan dan kenapa) - Kenapa
DELETEcukup satu klausul - Kenapa VIEW tetap wajib walau RLS sudah ON
- DDL production-ready (lengkap: policy anon, security barrier, index)
- Pola fetch: server-first, client buat interaksi
Let’s go.
TL;DR (buat yang buru-buru)
- RLS: filter baris apa yang boleh diakses user.
- VIEW: pilih kolom apa yang boleh tampil.
- GRANT: role siapa yang boleh pakai apa.
- Default: semua operasi DB (SELECT/INSERT/UPDATE/DELETE) via server. Client hanya untuk data publik ringan / interaksi.
- Anti-pattern:
select *dari tabel mentah di client.
1) Cara Mikir RLS: “Satpam Cerdas per Baris”
Bayangin tiap baris punya satpam.
Setiap query, satpam cek KTP kamu (auth.uid()) sebelum melewatkan baris itu.
Contoh tabel dasar:
create table profiles (
id uuid primary key,
user_id uuid references auth.users,
full_name text,
email text,
avatar_url text,
is_public boolean default false,
is_admin boolean default false
);
alter table profiles enable row level security;
Tanpa policy, semua akses ditolak. Kamu wajib eksplisit menulis aturan.
2) USING vs WITH CHECK
USING→ filter baris yang boleh kamu sentuh/lihat (SELECT, UPDATE, DELETE).WITH CHECK→ validasi nilai akhir saat menulis (INSERT, UPDATE).
Contoh update profil “hanya miliknya”:
create policy "self update"
on profiles for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
USING: boleh menyentuh baris yanguser_id= kamu.WITH CHECK: hasil akhir update tetapuser_id= kamu (nggak bisa nyolong baris orang).
Kenapa DELETE cuma USING?
Karena DELETE nggak menciptakan baris baru. Cukup tanya: “kamu berhak hapus baris ini?”
Kalau iya, hapus. Tidak perlu WITH CHECK.
create policy "self delete"
on profiles for delete
to authenticated
using (auth.uid() = user_id);
3) Kenapa Tetap Perlu VIEW?
RLS menjawab: baris mana yang boleh. VIEW menjawab: kolom apa yang boleh keluar.
Kamu boleh punya policy super aman, tapi kalau klien melakukan
select *, semua kolom non-sensitif dan sensitif ikut keluar. Itu sebabnya jangan pernah expose tabel mentah untuk konsumsi publik. Bungkus lewat VIEW.
security_barrier (opsional tapi mantap)
Tambahkan with (security_barrier) supaya planner Postgres tidak “mengakali” urutan evaluasi yang bisa memicu function leak.
4) DDL “Production-Ready”
Blok ini bisa kamu jalankan mentah-mentah. Lengkap: default UUID, unique, policy anon, view aman, index partial.
-- 0) Extension UUID (kalau belum)
-- create extension if not exists pgcrypto;
-- 1) Tabel private
drop table if exists profiles cascade;
create table profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
full_name text,
email text, -- pertimbangkan: sumber kebenaran tetap di auth.users
avatar_url text,
is_public boolean not null default false,
is_admin boolean not null default false,
unique (user_id)
);
-- 2) RLS ON
alter table profiles enable row level security;
-- 3) SELECT policies
-- a) Anon boleh baca yang publik
create policy "public can read public profiles"
on profiles for select
to anon
using (is_public = true);
-- b) Auth boleh baca miliknya + (opsional) publik orang lain
create policy "auth can read self or public"
on profiles for select
to authenticated
using (auth.uid() = user_id OR is_public = true);
-- 4) INSERT policy
create policy "self insert"
on profiles for insert
to authenticated
with check (auth.uid() = user_id);
-- 5) UPDATE policy
create policy "self update"
on profiles for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- 6) DELETE policy
create policy "self delete"
on profiles for delete
to authenticated
using (auth.uid() = user_id);
-- 7) VIEW publik (kontrak aman)
drop view if exists public_profiles;
create view public_profiles
with (security_barrier)
as
select
id,
full_name,
avatar_url
from profiles
where is_public = true;
grant select on public_profiles to anon;
-- 8) Index untuk performa (opsional tapi recommended)
create index if not exists profiles_is_public_true_idx
on profiles (is_public) where is_public = true;
Catatan penting:
grant select on public_profiles to anontidak mem-bypass RLS base-table. Karena itu kita tulis policyto anon using (is_public = true)pada tabelprofiles.
5) Anti-Pattern yang Sering Bikin Bocor
-
Langsung query tabel mentah di client:
from('profiles').select('*')→ jangan. Gunakan server (SSR/ISR) atau VIEW publik yang kolomnya aman. -
Lupa policy untuk
anonpadahal view ingin diakses publik. Hasilnya: view selalu kosong (RLS base-table ngeblok). -
Mengandalkan nama schema
publicsebagai izin publik.public.profilesitu sekadar nama schema, bukan akses. -
Expose Service Role ke client. Ini kartu akses master; server-only.
6) Pola Fetch: Server-First
Default semua operasi (SELECT penting/privat, INSERT, UPDATE, DELETE) lakukan di server: Server Component, Route Handler, atau Server Action.
Render awal (SSR/ISR) untuk data publik
// app/(public)/profiles/page.tsx
import { createServerClient } from '@supabase/ssr'
import { headers, cookies } from 'next/headers'
export const revalidate = 300 // ISR 5 menit
export default async function Page() {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ headers: { get: headers().get }, cookies: { get: (k) => cookies().get(k)?.value } }
)
const { data, error } = await supabase
.from('public_profiles')
.select('*')
.order('full_name', { ascending: true })
.range(0, 11)
if (error) throw error
return <ProfilesList initialData={data ?? []} />
}
Interaksi (search/pagination) — opsi:
- Client → API kamu → Supabase (lebih rapi: bisa rate-limit, cache header, bentuk respons)
- atau langsung client → view publik (opsi kedua; sadar trade-off SEO/biaya)
Untuk data privat/berbasis identitas user, selalu lewat server agar RLS memiliki konteks sesi tanpa mengirim token ke browser.
7) Testing Checklist (Supabase SQL Editor)
Pakai kueri ini untuk validasi cepat:
-- 1) Non-login (anon) bisa baca publik?
select * from public_profiles limit 5;
-- 2) Non-login tidak bisa baca tabel mentah?
select * from profiles limit 5; -- harus ditolak RLS / kosong
-- 3) Login user A hanya bisa baca miliknya / publik orang lain?
-- (tes via client dengan session A)
-- 4) Update milik sendiri lolos; update user_id ke orang lain ditolak?
-- (RLS WITH CHECK harus menolak)
8) Bonus: Kolom Sensitif & Postgres 15+
RLS = baris. Untuk level kolom:
- VIEW: cara paling umum & sederhana.
- Column masking (Postgres 15+): bisa jadi advanced option, tapi untuk skenario web umum, VIEW sudah cukup dan lebih eksplisit.
9) Ringkasan Mental Model
| Layer | Tugas |
|---|---|
| RLS | Tentukan baris mana yang boleh lewat |
| VIEW | Tentukan kolom apa yang boleh tampil |
| GRANT | Tentukan role siapa yang boleh akses |
“
publicdipublic.profilesbukan berarti datanya public.” Itu hanya nama schema, bukan izin akses.