Supabase Permissions: The Two Layers Nobody Explains
I spent hours debugging a single error: permission denied for table thoughts. The error message was clear. The cause was not.
The problem: Supabase doesn't have one, but two independent permission layers — and both need to be correct.
Layer 1: Row Level Security (RLS)
RLS is what everyone talks about. You enable it on a table, define policies — who can read or write which rows. This is the visible, documented layer.
CREATE POLICY "thoughts_public_read"
ON thoughts FOR SELECT
TO anon, authenticated
USING (status = 'published');
Looks good. Still doesn't work. Why?
Layer 2: Table-Level GRANTs
Nobody talks about this — but beneath RLS lies a second layer: classic PostgreSQL GRANTs. These decide whether a role can see the table at all. Without a GRANT, even the best RLS policy is useless.
What caught me off guard: Supabase has three different roles, each requiring separate GRANTs:
| Role | Used for | GRANT |
|---|---|---|
anon |
Portfolio reads published posts | SELECT |
authenticated |
Admin panel reads and writes | SELECT, INSERT, UPDATE, DELETE |
service_role |
Build script reads all posts | ALL |
The critical mistake was service_role. The build script that generates my blog posts uses the service role key — and it had no GRANT. Admin panel? Worked. Portfolio? Worked. But node scripts/build-thoughts.mjs failed.
The complete fix
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
GRANT SELECT ON thoughts TO anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON thoughts TO authenticated;
GRANT ALL ON thoughts TO service_role;
Plus the RLS policies on top. Always both together.
The rule
With any permission denied error in Supabase: check GRANTs first, then RLS. Both layers, all roles. This isn't stated clearly anywhere in the official documentation — so now it's stated here.
Supabase-Berechtigungen: die zwei Schichten, die niemand erklärt
Ich habe Stunden damit verbracht, einen einzigen Fehler zu debuggen: permission denied for table thoughts. Die Fehlermeldung war klar. Die Ursache war es nicht.
Das Problem: Supabase hat nicht eine, sondern zwei unabhängige Berechtigungsschichten — und beide müssen stimmen.
Schicht 1: Row Level Security (RLS)
RLS ist das, worüber alle reden. Du aktivierst es auf einer Tabelle, definierst Policies — wer darf welche Zeilen lesen oder schreiben. Das ist die sichtbare, dokumentierte Schicht.
CREATE POLICY "thoughts_public_read"
ON thoughts FOR SELECT
TO anon, authenticated
USING (status = 'published');
Sieht gut aus. Funktioniert trotzdem nicht. Warum?
Schicht 2: Table-Level GRANTs
Darüber spricht niemand — aber unter RLS liegt eine zweite Schicht: die klassischen PostgreSQL-GRANTs. Diese entscheiden, ob eine Rolle die Tabelle überhaupt sehen darf. Ohne GRANT hilft die beste RLS-Policy nichts.
Was mich kalt erwischt hat: Supabase hat drei verschiedene Rollen, die alle separate GRANTs brauchen:
| Rolle | Wofür | GRANT |
|---|---|---|
anon |
Portfolio liest veröffentlichte Posts | SELECT |
authenticated |
Admin Panel liest und schreibt | SELECT, INSERT, UPDATE, DELETE |
service_role |
Build-Script liest alle Posts | ALL |
Der entscheidende Fehler war service_role. Das Build-Script, das meine Blog-Posts generiert, nutzt den Service-Role-Key — und der hatte keinen GRANT. Admin Panel? Hat funktioniert. Portfolio? Hat funktioniert. Aber node scripts/build-thoughts.mjs schlug fehl.
Die vollständige Lösung
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
GRANT SELECT ON thoughts TO anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON thoughts TO authenticated;
GRANT ALL ON thoughts TO service_role;
Plus die RLS-Policies obendrauf. Immer beides zusammen.
Die Regel
Bei jedem permission denied-Fehler in Supabase: erst GRANTs prüfen, dann RLS. Beide Schichten, alle Rollen. Das steht so nirgends in der offiziellen Dokumentation — deshalb steht es jetzt hier.
صلاحيات Supabase: الطبقتان اللتان لا يشرحهما أحد
أمضيتُ ساعات في تتبع خطأ واحد: permission denied for table thoughts. الرسالة كانت واضحة. السبب لم يكن كذلك.
المشكلة: لا تملك Supabase طبقة صلاحيات واحدة، بل طبقتين مستقلتين — وكلتاهما يجب أن تكونا صحيحتين.
الطبقة الأولى: أمان مستوى الصف (RLS)
RLS هو ما يتحدث عنه الجميع. تُفعِّله على جدول وتُعرِّف سياسات — من يستطيع قراءة أو كتابة أيّ صفوف. هذه هي الطبقة المرئية والموثقة.
CREATE POLICY "thoughts_public_read"
ON thoughts FOR SELECT
TO anon, authenticated
USING (status = 'published');
يبدو جيداً. لا يعمل رغم ذلك. لماذا؟
الطبقة الثانية: صلاحيات مستوى الجدول (GRANTs)
لا أحد يتحدث عن هذا — لكن تحت RLS توجد طبقة ثانية: صلاحيات PostgreSQL الكلاسيكية. هي التي تحدد ما إذا كان دور ما يستطيع رؤية الجدول أصلاً. بدون GRANT لا تُجدي أفضل سياسة RLS نفعاً.
ما فاجأني: تملك Supabase ثلاثة أدوار مختلفة، كل منها يحتاج GRANTs منفصلة:
| الدور | الاستخدام | الصلاحية |
|---|---|---|
anon |
المحفظة تقرأ المنشورات المنشورة | SELECT |
authenticated |
لوحة الإدارة تقرأ وتكتب | SELECT, INSERT, UPDATE, DELETE |
service_role |
سكريبت البناء يقرأ جميع المنشورات | ALL |
الخطأ الحاسم كان في service_role. السكريبت الذي يُولِّد منشورات مدونتي يستخدم مفتاح service role — ولم يكن يملك GRANT. لوحة الإدارة؟ عملت. المحفظة؟ عملت. لكن node scripts/build-thoughts.mjs فشل.
الحل الكامل
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
GRANT SELECT ON thoughts TO anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON thoughts TO authenticated;
GRANT ALL ON thoughts TO service_role;
مع إضافة سياسات RLS فوقها. دائماً كلاهما معاً.
القاعدة
مع أي خطأ permission denied في Supabase: تحقق من GRANTs أولاً، ثم RLS. كلتا الطبقتين، جميع الأدوار. هذا غير مُوضَّح بوضوح كافٍ في التوثيق الرسمي — لذا أوضحته هنا.
