CRUD di Supabase bukan hanya soal menjalankan select, insert, update, dan delete. Aplikasi juga harus memastikan bahwa pengguna tidak dapat membaca atau mengubah baris milik pengguna lain. Pendekatan yang tepat adalah menggabungkan Supabase client, tipe TypeScript yang dihasilkan dari schema PostgreSQL, dan Row Level Security (RLS).
Pada contoh ini, kita membuat tabel tasks dengan kolom user_id. Setiap operasi dari aplikasi Next.js dijalankan menggunakan sesi pengguna, sedangkan policy PostgreSQL membatasi akses berdasarkan auth.uid(). Dengan begitu, validasi keamanan tidak hanya bergantung pada kode frontend.
Arsitektur CRUD yang aman
Alur yang direkomendasikan adalah sebagai berikut:
- Pengguna login melalui Supabase Auth.
- Next.js membuat Supabase client dengan sesi pengguna tersebut.
- Query dikirim ke Supabase menggunakan anon key dan token pengguna.
- PostgreSQL mengevaluasi RLS policy menggunakan
auth.uid(). - Query hanya berhasil untuk baris yang memenuhi policy.
Anon key memang boleh digunakan di browser selama RLS aktif dan policy disusun dengan benar. Sebaliknya, service role key tidak boleh dikirim ke browser karena key tersebut dapat melewati RLS.
Membuat tabel PostgreSQL di Supabase
Buka SQL Editor di dashboard Supabase, lalu jalankan schema berikut. Tabel ini menyimpan daftar tugas milik pengguna yang sedang login.
create table public.tasks (
id bigint generated by default as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
title text not null check (char_length(trim(title)) between 1 and 200),
completed boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index tasks_user_id_idx on public.tasks(user_id);
alter table public.tasks enable row level security;Kolom user_id menghubungkan setiap tugas dengan pengguna di auth.users. Foreign key dan on delete cascade membantu menjaga konsistensi ketika akun dihapus. Index pada user_id juga mendukung query yang memfilter data berdasarkan pemilik.
Memperbarui timestamp secara konsisten
Jika aplikasi perlu memperbarui updated_at setiap kali baris diubah, gunakan trigger PostgreSQL. Ini lebih dapat diandalkan daripada mengharuskan setiap caller mengirim timestamp.
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger tasks_set_updated_at
before update on public.tasks
for each row
execute function public.set_updated_at();Menerapkan Row Level Security
Policy berikut membatasi seluruh operasi berdasarkan pengguna yang sedang login. auth.uid() mengembalikan UUID pengguna dari JWT Supabase. Jika tidak ada sesi login, nilainya biasanya null, sehingga perbandingan dengan user_id tidak terpenuhi.
create policy "Users can read their own tasks"
on public.tasks
for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can insert their own tasks"
on public.tasks
for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can update their own tasks"
on public.tasks
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users can delete their own tasks"
on public.tasks
for delete
to authenticated
using ((select auth.uid()) = user_id);Pada operasi select dan delete, using menentukan baris yang boleh dibaca atau dihapus. Pada insert, with check memvalidasi baris baru. Pada update, keduanya diperlukan: using mencegah pengguna mengubah baris orang lain, sedangkan with check mencegah pengguna mengganti user_id menjadi akun lain.
Catatan: Jangan hanya memfilter query dengan .eq('user_id', user.id). Filter aplikasi membantu efisiensi, tetapi RLS tetap diperlukan sebagai batas keamanan di database.Menyiapkan Supabase client di Next.js
Instal library JavaScript Supabase yang digunakan proyek Anda, lalu simpan konfigurasi publik pada environment variable. Nama variable dapat disesuaikan dengan setup Next.js Anda.
NEXT_PUBLIC_SUPABASE_URL=https://project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyClient untuk browser dapat dibuat seperti ini:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);Untuk operasi yang berjalan di Server Component, Route Handler, atau Server Action, gunakan integrasi SSR resmi Supabase agar cookie sesi dapat dibaca dan ditulis dengan benar. Detail helper dapat berbeda bergantung pada struktur aplikasi dan versi library, tetapi prinsipnya sama: server client harus meneruskan access token pengguna, bukan menggunakan service role key.
Menghasilkan dan menggunakan tipe TypeScript
Generated types membuat schema database menjadi kontrak TypeScript. Setelah schema berubah, generate ulang file tipe menggunakan Supabase CLI. Contoh berikut menggunakan project reference dan menyimpan hasilnya ke folder src:
supabase gen types typescript --project-id <PROJECT_REF> > src/database.types.tsAlternatifnya, tipe dapat dihasilkan dari database lokal jika proyek menggunakan Supabase CLI lokal. Jangan lupa memasukkan file tersebut ke version control jika tim membutuhkannya sebagai bagian dari source code.
Gunakan tipe tabel untuk membedakan bentuk data saat membaca dan saat melakukan insert atau update:
import type { Database } from '@/database.types';
type Task = Database['public']['Tables']['tasks']['Row'];
type TaskInsert = Database['public']['Tables']['tasks']['Insert'];
type TaskUpdate = Database['public']['Tables']['tasks']['Update'];Dengan tipe ini, kolom wajib, nama field, dan tipe nilai akan diperiksa saat compile time. Generated types tidak menggantikan RLS; tipe hanya membantu mencegah kesalahan di kode aplikasi.
Implementasi operasi CRUD
Create: menambahkan tugas
Nilai user_id harus berasal dari sesi pengguna yang terautentikasi. Jangan menerima user_id mentah dari input publik lalu mempercayainya.
import { supabase } from '@/lib/supabase';
import type { Database } from '@/database.types';
type TaskInsert = Database['public']['Tables']['tasks']['Insert'];
export async function createTask(userId: string, title: string) {
const value = title.trim();
if (!value) {
throw new Error('Judul tugas wajib diisi');
}
const payload: TaskInsert = {
user_id: userId,
title: value,
};
const { data, error } = await supabase
.from('tasks')
.insert(payload)
.select()
.single();
if (error) {
console.error('createTask failed', error);
throw new Error('Tugas tidak dapat dibuat');
}
return data;
}Method insert tidak selalu mengembalikan baris yang baru dibuat tanpa select(). Meminta hasil dengan select().single() berguna ketika UI membutuhkan ID atau timestamp dari record baru.
Read: mengambil tugas pengguna
export async function listTasks() {
const { data, error } = await supabase
.from('tasks')
.select('id, title, completed, created_at, updated_at')
.order('created_at', { ascending: false });
if (error) {
console.error('listTasks failed', error);
throw new Error('Tugas tidak dapat dimuat');
}
return data;
}Query ini tidak perlu menambahkan filter user_id agar aman karena policy select akan menghapus baris yang bukan milik pengguna. Filter tambahan dapat digunakan untuk optimasi, tetapi bukan sebagai satu-satunya mekanisme otorisasi.
Update: mengubah tugas
type TaskUpdate = Database['public']['Tables']['tasks']['Update'];
export async function updateTask(id: number, changes: TaskUpdate) {
const allowedChanges: TaskUpdate = {};
if (typeof changes.title === 'string') {
const title = changes.title.trim();
if (!title) throw new Error('Judul tugas wajib diisi');
allowedChanges.title = title;
}
if (typeof changes.completed === 'boolean') {
allowedChanges.completed = changes.completed;
}
const { data, error } = await supabase
.from('tasks')
.update(allowedChanges)
.eq('id', id)
.select()
.single();
if (error) {
console.error('updateTask failed', error);
throw new Error('Tugas tidak dapat diperbarui');
}
return data;
}Policy tetap memverifikasi pemilik meskipun query hanya menggunakan ID tugas. Membatasi field yang dapat diubah juga mencegah client mengirim kolom sensitif seperti user_id atau created_at.
Delete: menghapus tugas
export async function deleteTask(id: number) {
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', id);
if (error) {
console.error('deleteTask failed', error);
throw new Error('Tugas tidak dapat dihapus');
}
}Jika ID tersebut bukan milik pengguna, RLS mencegah penghapusan. Dalam beberapa kasus, operasi dapat selesai tanpa error tetapi tidak memengaruhi baris apa pun karena baris tersebut tidak terlihat oleh role pengguna. Jika aplikasi memerlukan kepastian bahwa tepat satu baris berubah, gunakan select().single() setelah operasi dan tangani kondisi tidak ditemukan sesuai kebutuhan UI.
Penanganan error dan sesi pengguna
Pastikan operasi CRUD hanya dipanggil setelah sesi tersedia. Pada server, ambil user dari sesi yang telah divalidasi, bukan dari body request. Pada browser, UI dapat memantau perubahan auth state, tetapi keputusan akses tetap dilakukan oleh RLS.
const { data: { user }, error: authError } =
await supabase.auth.getUser();
if (authError || !user) {
throw new Error('Anda harus login');
}
await createTask(user.id, 'Pelajari RLS');Jangan menampilkan detail error database mentah kepada pengguna akhir karena dapat membocorkan nama tabel, constraint, atau informasi internal. Simpan detail teknis di log server, lalu kembalikan pesan yang aman dan relevan. Untuk error validasi input, gunakan pesan yang spesifik; untuk error otorisasi atau database, gunakan pesan umum.
Praktik menghindari kebocoran data
- Aktifkan RLS pada setiap tabel yang berisi data pengguna. Membuat policy tanpa mengaktifkan RLS tidak memberikan perlindungan.
- Gunakan role yang tepat. Policy biasanya ditujukan ke
authenticated; jangan membuka akses keanonkecuali memang diperlukan. - Jangan memasukkan service role key ke environment variable publik seperti variable dengan prefix
NEXT_PUBLIC_atau ke kode client. - Validasi kepemilikan di database. Policy harus tetap aman walaupun endpoint dipanggil langsung tanpa melalui UI.
- Pilih kolom secara eksplisit menggunakan
select('id, title, completed')agar kolom internal tidak ikut terkirim tanpa sengaja. - Jangan menerima user ID sebagai otoritas. Ambil identitas dari token atau sesi yang telah diverifikasi.
- Uji akses lintas pengguna. Buat dua akun, lalu pastikan akun pertama tidak dapat membaca, mengubah, atau menghapus data akun kedua.
Debugging RLS yang praktis
Jika query mengembalikan error permission atau data kosong, periksa hal-hal berikut:
- Pastikan user benar-benar login dan token sesi dikirim oleh client.
- Pastikan RLS aktif pada tabel yang benar.
- Periksa policy untuk operasi yang gagal:
select,insert,update, ataudelete. - Untuk insert dan update, pastikan ekspresi
with checkmengizinkan nilaiuser_idyang dikirim. - Pastikan query server menggunakan Supabase client berbasis sesi pengguna, bukan client anonim tanpa cookie.
- Periksa tipe data ID dan UUID.
tasks.idpada contoh adalahbigint, sedangkanuser_idadalahuuid.
Uji policy secara langsung dari dashboard atau dengan dua akun berbeda. Pengujian positif saja tidak cukup: pastikan pengguna dapat mengakses datanya sendiri dan secara bersamaan tidak dapat mengakses data pengguna lain.
Kesimpulan
CRUD aman dengan Supabase Database dan Row Level Security dibangun dari tiga lapisan: schema PostgreSQL yang memiliki relasi pemilik, generated types untuk menjaga konsistensi kode Next.js, dan RLS policy yang menegakkan otorisasi di database. Query Supabase tetap sederhana, tetapi keamanan tidak bergantung pada filter frontend atau parameter dari client.
Mulailah dengan schema minimal, aktifkan RLS sebelum data digunakan aplikasi, buat policy untuk setiap operasi, lalu uji skenario lintas pengguna. Struktur ini memberi perlindungan yang tetap berlaku meskipun API dipanggil langsung atau terdapat bug pada UI.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!