Vito Solin
  • Portfolio
  • Blog

© 2025 Vito Solin. All rights reserved.

← Back to Blog
supabase

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 USING vs WITH CHECK (pakai kapan dan kenapa)
  • Kenapa DELETE cukup 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 yang user_id = kamu.
  • WITH CHECK: hasil akhir update tetap user_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 anon tidak mem-bypass RLS base-table. Karena itu kita tulis policy to anon using (is_public = true) pada tabel profiles.


5) Anti-Pattern yang Sering Bikin Bocor

  1. Langsung query tabel mentah di client: from('profiles').select('*') → jangan. Gunakan server (SSR/ISR) atau VIEW publik yang kolomnya aman.

  2. Lupa policy untuk anon padahal view ingin diakses publik. Hasilnya: view selalu kosong (RLS base-table ngeblok).

  3. Mengandalkan nama schema public sebagai izin publik. public.profiles itu sekadar nama schema, bukan akses.

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

LayerTugas
RLSTentukan baris mana yang boleh lewat
VIEWTentukan kolom apa yang boleh tampil
GRANTTentukan role siapa yang boleh akses

“public di public.profiles bukan berarti datanya public.” Itu hanya nama schema, bukan izin akses.


Rekomendasi Bacaan

Sebelumnya
Belajar Row Level Security Supabase
supabase • #2
Selanjutnya
Kapan pakai auth.uid() dan kapan pakai auth.jwt() dalam RLS Supabase?
supabase • #4