uurstudios
Tüm yazılar
SupabasePostgreSQLGüvenlikBackend

Supabase'de Row Level Security'yi doğru kurmak: anahtarlar ve politikalar

30 Eylül 2026 6 dk okumaUğur Can Elsun

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ırTarayıcı, mobil uygulama, istemci koduYalnızca sunucu: backend, edge function, cron işi
RLSUygulanırAtlanır
Gizli miHayır, herkes görebilirEvet, 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 authenticated yazmak, 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 bir WHERE gibi 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 bilgisi app_metadata içinde ya da ayrı bir tabloda durmalı.
  • View'ların politikaları atladığını fark etmemek. Postgres 15 ve sonrasında view'ı security_invoker = true ile 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:

  1. Panelin API anahtarları bölümünde yeni bir secret anahtar oluşturun.
  2. Sunucularınızdaki, edge function'lardaki ve zamanlanmış işlerinizdeki ortam değişkenlerini yeni anahtarla güncelleyin ve yeniden dağıtın.
  3. 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_role anahtarıysa yeni anahtarlara geçtikten sonra eski anahtarları devre dışı bırakın.
  4. 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.
  5. Aynı .env dosyasında duran diğer sırları (ödeme, e-posta, depolama anahtarları) da değiştirin. Biri sızdıysa hepsi sızmış kabul edilmeli.
  6. 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.
  7. 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 using hem with check var mı?
  • Politikalarda (select auth.uid()) ve to authenticated kullanıldı mı, filtre sütunları indeksli mi?
  • Yetki kontrolü user_metadata yerine güvenilir bir kaynağa mı dayanıyor?
  • View'lar security_invoker ile 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.

İlgili yazılar

Bir projeniz mi var?

Fikrinizi anlatın, 24 saat içinde size özel bir teklif ve yol haritasıyla dönüş yapayım.

Teklif Al
WhatsApp