Jika upload ke Supabase Storage selalu gagal dengan 403 atau pesan new row violates row-level security policy padahal pengguna sudah login, penyebabnya biasanya bukan proses login. Penyebab yang paling sering adalah policy RLS pada storage.objects tidak cocok dengan bucket_id atau path objek yang benar, policy INSERT ditulis dengan klausa yang keliru, atau role authenticated tidak mendapat izin yang diperlukan.

Pada Supabase Storage, login hanya membuat request membawa JWT pengguna. JWT tersebut kemudian dievaluasi oleh policy RLS ketika Storage mencoba membuat baris metadata objek di tabel storage.objects. Jika ekspresi policy bernilai salah, upload tetap ditolak.

Gejala Debug Upload Supabase Storage 403 di browser dan API

Di browser, gejalanya umumnya terlihat pada Network tab atau hasil error dari SDK. Request upload diarahkan ke endpoint Storage dengan pola /storage/v1/object/<bucket-id>/<object-path>. Status respons dapat berupa 403, sedangkan isi body error sering mengarah ke penolakan policy RLS. Detail format respons dapat berbeda menurut endpoint dan versi layanan, tetapi indikator pentingnya adalah kegagalan otorisasi saat objek dibuat.

const { data, error } = await supabase.storage
  .from('user-uploads')
  .upload('3c89b7d1-.../avatar.png', file, {
    upsert: false,
    contentType: file.type
  })

if (error) {
  console.error({ message: error.message, name: error.name })
}

Jangan mencetak access token, header Authorization, atau seluruh object session ke console maupun sistem log. Untuk investigasi awal, simpan hanya nama error, status bila tersedia, bucket, dan path yang telah disanitasi.

Pada Dashboard Supabase, periksa log Storage atau log API pada rentang waktu request terjadi. Cari request ke bucket dan path yang sama, lalu cocokkan dengan waktu error browser. Jika tersedia log database yang terkait, pesan pelanggaran RLS pada storage.objects memperkuat bahwa masalahnya ada pada policy, bukan pada MIME type atau ukuran file.

Catatan: bucket public hanya memengaruhi cara file dibaca secara publik. Bucket public tidak otomatis mengizinkan pengguna mengunggah file. Upload tetap memerlukan policy RLS yang sesuai.

Studi kasus: pengguna login, tetapi policy memeriksa bucket yang salah

Misalkan aplikasi membuat bucket dengan ID user-uploads. Frontend membangun key objek berdasarkan ID pengguna:

const path = `${user.id}/${crypto.randomUUID()}-${file.name}`

await supabase.storage
  .from('user-uploads')
  .upload(path, file, { upsert: false })

Namun, policy yang aktif masih memeriksa bucket_id = 'uploads', nama bucket lama. Request membawa JWT yang valid dan auth.uid() juga benar, tetapi baris baru memiliki bucket_id = 'user-uploads'. Karena syarat bucket tidak cocok, evaluasi policy bernilai false dan insert ditolak.

-- Contoh policy bermasalah: bucket_id tidak sama dengan bucket yang dipakai frontend
create policy 'upload ke bucket lama'
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'uploads'
);

Kesalahan ini mudah terjadi ketika bucket dihapus lalu dibuat ulang, nama bucket pada kode berubah, atau tim menyamakan nama tampilan dengan identifier bucket. Pada policy storage.objects, gunakan nilai ID bucket yang benar dan cocokkan persis dengan parameter pada .from(...).

Reproduksi dan pemeriksaan konfigurasi

1. Pastikan ID bucket yang digunakan

Periksa bucket dari Dashboard Storage, atau jalankan query berikut melalui SQL Editor dengan hak administratif yang sesuai:

select id, name, public
from storage.buckets
order by id;

Bandingkan kolom id dengan kode frontend berikut:

supabase.storage.from('user-uploads')

Nilai user-uploads harus identik dengan storage.buckets.id yang dirujuk oleh storage.objects.bucket_id. Jangan mengandalkan asumsi dari nama variabel, environment variable, atau bucket yang dipakai pada project Supabase lain.

2. Periksa policy yang benar-benar aktif

Daftar policy aktif dapat diperiksa dari katalog PostgreSQL:

select policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'storage'
  and tablename = 'objects'
order by policyname;

Perhatikan terutama tiga hal: role pada kolom roles, operasi pada cmd, dan ekspresi pada with_check. Policy untuk SELECT tidak memberi hak upload; policy khusus INSERT harus ada untuk membuat objek baru.

3. Verifikasi identitas pengguna tanpa membocorkan token

Pastikan aplikasi benar-benar memiliki pengguna aktif sebelum upload. Contoh berikut hanya mencatat ID pengguna dan tidak mencetak JWT:

const { data, error } = await supabase.auth.getUser()

if (error || !data.user) {
  throw new Error('Tidak ada pengguna terautentikasi')
}

console.info({ userId: data.user.id })

Untuk memeriksa claim JWT seperti sub dan role, gunakan Network tab secara lokal atau decoder JWT lokal pada lingkungan pengembangan. Jangan menyalin token ke issue tracker, chat, log cloud, atau situs decoder pihak ketiga. Pada alur pengguna biasa yang berhasil login, role JWT umumnya adalah authenticated; policy harus menargetkan role itu bila akses upload memang ditujukan untuk pengguna login.

Root cause policy RLS yang paling umum

INSERT harus memakai WITH CHECK, bukan USING

Untuk operasi INSERT, PostgreSQL mengevaluasi klausa WITH CHECK terhadap nilai baris baru. Klausa ini menentukan objek seperti apa yang boleh dibuat. Sementara itu, USING lazim digunakan untuk menentukan baris yang boleh terlihat, diubah, atau dihapus pada SELECT, UPDATE, dan DELETE.

Policy upload yang aman harus membatasi bucket_id dan memastikan folder pertama pada object key adalah ID pengguna yang sedang login:

create policy 'authenticated upload hanya folder sendiri'
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

Dengan policy ini, pengguna dengan ID A boleh membuat A/foto.png, tetapi tidak boleh membuat B/foto.png. Ekspresi storage.foldername(name) membaca segmen folder dari key objek, sehingga frontend wajib memakai format path yang konsisten.

Jika key tidak memakai folder pengguna, misalnya hanya avatar.png, elemen folder pertama tidak akan cocok dengan auth.uid() dan upload akan ditolak. Itu adalah perilaku yang diharapkan, bukan bug policy.

Role authenticated tidak tercakup

Policy juga harus ditujukan ke role yang benar. Policy tanpa target authenticated, atau policy yang hanya diberikan ke role lain, tidak akan meloloskan request pengguna login. Gunakan target eksplisit agar maksud akses mudah diaudit:

create policy 'authenticated upload hanya folder sendiri'
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

Hindari membuat policy luas seperti with check (true) pada bucket yang menerima input pengguna. Policy tersebut memungkinkan setiap pengguna yang memenuhi role membuat objek pada path mana pun di bucket tersebut, termasuk folder pengguna lain bila bucket dipakai bersama.

Policy lengkap untuk bucket privat per pengguna

Upload saja jarang cukup. Jika aplikasi menampilkan, mengganti, atau menghapus file milik pengguna, buat policy berbeda per operasi. Berikut contoh untuk bucket user-uploads dengan key <auth.uid()>/<nama-file>:

create policy 'baca file folder sendiri'
on storage.objects
for select
to authenticated
using (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy 'upload file folder sendiri'
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy 'ubah file folder sendiri'
on storage.objects
for update
to authenticated
using (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
)
with check (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy 'hapus file folder sendiri'
on storage.objects
for delete
to authenticated
using (
  bucket_id = 'user-uploads'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);
  • INSERT + WITH CHECK: mengontrol baris objek baru yang boleh dibuat.
  • SELECT + USING: mengontrol objek yang boleh dilihat, diunduh melalui alur terautorisasi, atau didaftar.
  • UPDATE + USING dan WITH CHECK: USING membatasi objek lama yang boleh diubah; WITH CHECK mencegah hasil perubahan dipindahkan ke bucket atau folder pengguna lain.
  • DELETE + USING: mengontrol objek yang boleh dihapus.

Upload dengan upsert: false dan nama file unik terutama membutuhkan izin INSERT. Jika aplikasi menimpa objek yang sudah ada atau memakai opsi upsert, siapkan pula izin yang relevan untuk membaca dan memperbarui objek. Karena itu, untuk diagnosis awal gunakan key baru dan upsert: false agar masalah insert tidak tercampur dengan policy update.

Menguji perbaikan sebagai pengguna biasa

Jangan menguji policy RLS dengan service key karena hasilnya tidak merepresentasikan pengguna aplikasi. Uji menggunakan anon key di frontend atau test runner, lalu autentikasikan sebagai pengguna biasa sehingga request memakai JWT user tersebut.

  1. Buat atau gunakan akun uji A dan akun uji B.
  2. Login sebagai A, ambil ID pengguna dari getUser(), lalu upload ke A-id/test.png. Request harus berhasil.
  3. Masih sebagai A, coba upload ke B-id/test.png. Request harus ditolak.
  4. Sebagai A, coba upload ke bucket lain yang tidak diizinkan. Request harus ditolak.
  5. Login sebagai B dan pastikan B hanya dapat membaca, memperbarui, atau menghapus objek dalam folder B bila policy operasi tersebut dibuat.

Pastikan nama file dari pengguna tidak langsung dijadikan seluruh object key. Buat key di bawah prefix yang ditentukan server atau client tepercaya, gunakan nama unik, dan normalisasi penggunaan slash. Policy folder tidak menggantikan validasi jenis file, ukuran file, maupun pemeriksaan konten bila kebutuhan keamanan aplikasi mengharuskannya.

Jangan memindahkan service_role ke frontend

Memakai service_role di browser mungkin terlihat seperti solusi cepat karena role tersebut dapat melewati RLS. Namun, ini adalah celah keamanan serius: siapa pun yang mendapat key tersebut dapat melakukan operasi dengan hak administratif terhadap data yang dilindungi.

Simpan service role hanya di backend tepercaya, seperti server API, edge function dengan secret, atau worker. Gunakan hanya ketika backend memang perlu melakukan pekerjaan administratif, misalnya pemrosesan file terkontrol. Untuk upload langsung dari browser, pertahankan anon key publik dan JWT pengguna, lalu perbaiki policy RLS sesuai model otorisasi aplikasi.

Checklist setelah perbaikan dan regresi test

  • Bucket pada .from(...) sama persis dengan storage.buckets.id dan nilai bucket_id di policy.
  • Policy INSERT memakai WITH CHECK, bukan hanya USING.
  • Policy menargetkan authenticated bila upload dilakukan oleh pengguna login biasa.
  • Path upload mengikuti format folder yang diuji policy, misalnya <user-id>/<file-unik>.
  • Upload key baru dengan upsert: false berhasil untuk pemilik folder.
  • Pengguna A tidak dapat membuat, membaca, memperbarui, atau menghapus objek milik pengguna B sesuai operasi yang berlaku.
  • Test integration memakai dua JWT pengguna nyata atau akun test terisolasi, bukan service role.
  • Log aplikasi tidak menyimpan access token, authorization header, atau session lengkap.

Masukkan skenario allow dan deny tersebut ke regresi test. Test negatif sama pentingnya dengan test upload sukses: policy yang hanya diuji dengan pemilik folder dapat tampak benar, padahal pengguna lain masih mungkin menulis ke prefix yang tidak semestinya. Dengan memverifikasi bucket, role, key objek, dan policy per operasi, error 403 pada upload Supabase Storage dapat ditelusuri secara deterministik tanpa membuka akses Storage terlalu luas.