Supabase'de Row Level Security'yi doğru kurmak: anahtarlar ve politikalar
Supabase ile backend kurmak hızlı: tablo oluşturuyorsunuz, istemci kütüphanesini ekliyorsunuz ve uygulama veriyi doğrudan okumaya başlıyor. Bu hızın bir bedeli var. Klasik bir backend'de tarayıcı ile veritabanı arasında sizin yazdığınız bir API katmanı olur. Supabase'de o katmanın yerini Postgres'in Row Level Security (RLS) politikaları alıyor. Politikalar yanlışsa, veritabanınız internete açık demektir.
Bu yazıda anahtar türlerini, doğru politika yazmayı, sık yapılan hataları ve bir anahtar sızdığında neler yapmanız gerektiğini genel bir örnek üzerinden anlatacağım.
İki tür anahtar, iki farklı dünya
Supabase projesinde iki tür API anahtarı var. Yeni projelerde bunlar sb_publishable_... ve sb_secret_... önekleriyle geliyor. Eski projelerde aynı işi JWT biçimindeki anon ve service_role anahtarları yapıyor.
| Publishable (eski adı: anon) | Secret (eski adı: service_role) | |
|---|---|---|
| Nerede kullanılır | Tarayıcı, mobil uygulama, istemci kodu | Yalnızca sunucu: backend, edge function, cron işi |
| RLS | Uygulanır | Atlanır |
| Gizli mi | Hayır, herkes görebilir | Evet, veritabanının tam yetkili anahtarı |
Publishable anahtar kullanıcı giriş yapmamışsa anon, giriş yapmışsa authenticated rolüyle çalışıyor. Politikalar da bu rollere göre yazılıyor. Bu anahtarın uygulamanın içinde görünmesi normal; asıl güvenlik politikalardan geliyor.
Secret anahtar ise bypassrls yetkisine sahip service_role rolüyle çalışıyor. RLS'yi hiç görmez, her tabloyu okur ve yazar. Supabase, yeni secret anahtarları tarayıcıdan gelen isteklerde (User-Agent'a bakarak) reddediyor. Bu iyi bir güvenlik ağı ama eski service_role anahtarında böyle bir koruma yok. Supabase eski anahtarları 2026 sonuna kadar kullanımdan kaldırmayı planlıyor, bu yüzden eski bir projeniz varsa yenilerine geçmek için iyi bir zaman.
En sık gördüğüm kaza, secret anahtarın bir ortam değişkeni önekiyle istemciye sızması. Next.js'te NEXT_PUBLIC_, Vite'ta VITE_, Expo'da EXPO_PUBLIC_ ile başlayan değişkenler derleme sırasında istemci koduna gömülüyor. Secret anahtarı bu öneklerden biriyle adlandırdığınız an, uygulamanızı indiren herkes onu okuyabilir.
// İstemci: yalnızca publishable anahtar
const supabase = createClient(SUPABASE_URL, PUBLISHABLE_KEY);
// Sunucu: secret anahtar, oturum saklamadan
const admin = createClient(SUPABASE_URL, process.env.SUPABASE_SECRET_KEY, {
auth: { persistSession: false },
});
RLS'yi açmak ve ilk politikalar
Panelin tablo düzenleyicisiyle oluşturduğunuz tablolarda RLS varsayılan olarak açık geliyor. SQL ile, migration dosyasıyla oluşturduğunuz tablolarda ise kendiniz açmanız gerekiyor. RLS kapalı ve public şemasında duran bir tablo, publishable anahtarı olan herkes tarafından okunup yazılabilir.
alter table public.profiles enable row level security;
RLS açık ama hiç politika yoksa tablo, secret anahtar dışında herkese kapalı olur. Yani güvenli taraftan başlıyorsunuz; sonra ihtiyaç duyduğunuz erişimi tek tek açıyorsunuz.
Örnek bir profiles tablosu: herkes kendi profilini görsün ve düzenlesin.
create policy "Kendi profilini okur"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Kendi profilini oluşturur"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Kendi profilini günceller"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
using hangi satırların görülebileceğini ya da değiştirilebileceğini belirliyor. with check ise yazılan satırın son hâlinin kurala uyup uymadığını denetliyor. Güncelleme politikasında ikisini birden yazmak önemli. Yalnızca using yazarsanız kullanıcı kendi satırını güncelleyip user_id alanını başka birinin kimliğiyle değiştirebilir.
Üç küçük ayrıntı daha:
auth.uid()çağrısını(select auth.uid())içine almak, Postgres'in değeri her satır için değil, sorgu başına bir kez hesaplamasını sağlıyor. Büyük tablolarda fark ciddi.to authenticatedyazmak, politikanın giriş yapmamış istekler için hiç değerlendirilmemesini sağlıyor.- Politikada filtrelediğiniz sütuna (burada
user_id) indeks ekleyin. RLS koşulu her sorguya eklenen birWHEREgibi davranıyor.
Gerçek hayata yakın bir örnek: ekip bazlı erişim
Çoğu uygulamada veri kişiye değil ekibe ya da şirkete ait. Diyelim ki projects tablosu var ve bir kullanıcı yalnızca üyesi olduğu ekibin projelerini görmeli.
create table public.team_members (
team_id uuid not null references public.teams(id),
user_id uuid not null references auth.users(id),
role text not null default 'member',
primary key (team_id, user_id)
);
create index on public.team_members (user_id);
alter table public.team_members enable row level security;
alter table public.projects enable row level security;
create policy "Kendi üyeliklerini görür"
on public.team_members for select
to authenticated
using ( user_id = (select auth.uid()) );
create policy "Ekibinin projelerini görür"
on public.projects for select
to authenticated
using (
exists (
select 1 from public.team_members m
where m.team_id = projects.team_id
and m.user_id = (select auth.uid())
)
);
create policy "Yalnızca ekip yöneticisi proje ekler"
on public.projects for insert
to authenticated
with check (
exists (
select 1 from public.team_members m
where m.team_id = projects.team_id
and m.user_id = (select auth.uid())
and m.role = 'admin'
)
);
Burada dikkat edilecek bir tuzak var: team_members tablosunun kendi politikasında yine team_members tablosunu sorgularsanız (örneğin "aynı ekipteki herkesi görebilir" demek için), Postgres sonsuz döngü hatası veriyor. Bu durumda üyelik kontrolünü ayrı bir şemada, security definer olarak tanımlanmış bir fonksiyona taşımak yaygın çözüm. O fonksiyonu API'ye açık olmayan bir şemada tutmak gerekiyor.
Sık yapılan hatalar
using (true)ile "şimdilik herkes okusun" demek ve unutmak. Geliştirme aşamasında yazılan geçici politikalar yayında kalıyor.- Yetkiyi
user_metadataüzerinden kontrol etmek. Bu alanı kullanıcı kendisi değiştirebiliyor. Rol gibi yetki bilgisiapp_metadataiçinde ya da ayrı bir tabloda durmalı. - View'ların politikaları atladığını fark etmemek. Postgres 15 ve sonrasında view'ı
security_invoker = trueile oluşturursanız alttaki tabloların RLS kuralları uygulanıyor; yoksa view, sahibinin yetkileriyle çalışıyor. - Silme politikası yazmayı unutmak ya da tam tersi, hiç düşünmeden herkese açmak.
- Politikaları yalnızca uygulama arayüzünden test etmek. Arayüz başka bir kullanıcının verisini istemiyor diye API'nin de vermeyeceği anlamına gelmiyor.
Politikaları doğrudan SQL ile test etmek daha güvenilir. Bir işlem içinde rolü ve kullanıcı kimliğini taklit edip sonucu görebilirsiniz:
begin;
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"00000000-0000-0000-0000-000000000001","role":"authenticated"}', true);
select * from public.projects; -- yalnızca bu kullanıcının ekip projeleri gelmeli
rollback;
Panelin Security Advisor bölümü de RLS'si kapalı tabloları ve benzeri sorunları listeliyor. Her yayından önce buraya bakmak iyi bir alışkanlık.
Anahtar sızarsa ne yapmalı
Önce hangi anahtarın sızdığına bakın.
Publishable anahtar sızdıysa acil bir durum yok, zaten herkese açık olacak şekilde tasarlandı. Ama bunu bir fırsat olarak görüp politikaları gözden geçirin. O anahtarla neler yapılabildiğini bir kez de saldırgan gözüyle deneyin.
Secret ya da eski service_role anahtarı sızdıysa bunu veritabanının tamamen açığa çıkması olarak ele alın:
- Panelin API anahtarları bölümünde yeni bir secret anahtar oluşturun.
- Sunucularınızdaki, edge function'lardaki ve zamanlanmış işlerinizdeki ortam değişkenlerini yeni anahtarla güncelleyin ve yeniden dağıtın.
- Sızan secret anahtarı silin. Silme geri alınamıyor, bu yüzden önce bütün kullanım yerlerinin güncellendiğinden emin olun. Sızan eski
service_roleanahtarıysa yeni anahtarlara geçtikten sonra eski anahtarları devre dışı bırakın. - API günlüklerinde sızıntının olduğu tarihten bu yana olağan dışı istekleri arayın: bilinmeyen IP'ler, toplu okuma, beklenmedik silme ya da güncellemeler.
- Aynı
.envdosyasında duran diğer sırları (ödeme, e-posta, depolama anahtarları) da değiştirin. Biri sızdıysa hepsi sızmış kabul edilmeli. - Anahtar bir Git deposuna girdiyse geçmişi temizlemek iyi ama yeterli değil. Depo herkese açıktaysa anahtar ele geçirilmiş sayılır; asıl çözüm anahtarı geçersiz kılmak.
- Kişisel veriye erişilmiş olabilirse, KVKK kapsamındaki bildirim yükümlülüklerini bir hukukçuyla değerlendirin.
Yayın öncesi kısa kontrol listesi
publicşemasındaki her tabloda RLS açık mı?- İstemci kodunda ve derlenmiş pakette yalnızca publishable anahtar mı var?
- Güncelleme politikalarında hem
usinghemwith checkvar mı? - Politikalarda
(select auth.uid())veto authenticatedkullanıldı mı, filtre sütunları indeksli mi? - Yetki kontrolü
user_metadatayerine güvenilir bir kaynağa mı dayanıyor? - View'lar
security_invokerile mi tanımlı? - Politikaları başka bir kullanıcı kimliğiyle SQL üzerinden test ettiniz mi?
Mevcut bir Supabase projesinin politikalarını gözden geçirmemi isterseniz bana yazabilirsiniz.