[{"data":1,"prerenderedAt":16},["ShallowReactive",2],{"$fqSUTmmDPZoOfkmBPxTmpfwN5NnaQeuI-DHGeZS0W-FY":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"draft":13,"readingMinutes":14,"html":15},"supabase-rls","Supabase RLS: What It Actually Protects","Supabase row level security explained for 2026: grants vs policies, the publishable key, why UPDATE needs a SELECT policy, views that leak, and fast policies.","2026-10-21",[9,10,11,12],"supabase","postgres","security","rls",false,5,"\u003Cp>Supabase RLS (row level security) is Postgres deciding, inside the database, which rows each request may read or change. Your publishable key ships in the browser, so anyone can call your API with it; on a table in an exposed schema, a policy plus the table&#39;s grants are the only things standing between that key and every row.\u003C/p>\n\u003Cp>It is the first thing I would read in any Supabase project, before the frontend, before the edge functions. Most of what follows is what I look for, with the parts of the \u003Ca href=\"https://supabase.com/docs/guides/database/postgres/row-level-security\">Supabase RLS guide\u003C/a> that matter most, checked on October 21, 2026.\u003C/p>\n\u003Ch2>A policy is a WHERE clause you can&#39;t forget\u003C/h2>\n\u003Cp>The mental model that helps most people: a \u003Ccode>select\u003C/code> policy gets glued onto every query against that table as an extra \u003Ccode>where\u003C/code>.\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-sql\">create policy &quot;own todos&quot;\non todos for select\nto authenticated\nusing ( (select auth.uid()) = user_id );\n\u003C/code>\u003C/pre>\n\u003Cp>With that in place, \u003Ccode>supabase.from(&#39;todos&#39;).select(&#39;*&#39;)\u003C/code> from the browser returns the signed-in user&#39;s rows and nothing else, even though the query asked for everything. A signed-out request matches no policy at all and gets an empty array. Not an error, an empty array, which is worth remembering when you&#39;re debugging: RLS failing closed looks exactly like &quot;there&#39;s no data yet&quot;.\u003C/p>\n\u003Cimg src=\"/blog/supabase-rls/supabase-rls-three-keys.webp\" alt=\"One Supabase todos table seen three ways: the publishable key with no session gets zero rows, a signed-in user gets only their three rows, and the secret key gets all eight because it bypasses RLS\" width=\"1600\" height=\"900\" loading=\"lazy\">\u003Cp>The picture above is the whole idea. Same table, same query, three callers. The secret key (the old \u003Ccode>service_role\u003C/code>) has Postgres&#39;s \u003Ccode>BYPASSRLS\u003C/code> attribute, so no policy you write ever applies to it. That&#39;s why it belongs on a server and nowhere else.\u003C/p>\n\u003Ch2>Grants come first\u003C/h2>\n\u003Cp>This is the part almost nobody checks. Postgres runs two checks: grants decide whether a role may run \u003Ccode>select\u003C/code>, \u003Ccode>insert\u003C/code>, \u003Ccode>update\u003C/code> or \u003Ccode>delete\u003C/code> on the table at all, and only then do policies decide which rows. On existing Supabase projects a new table in \u003Ccode>public\u003C/code> starts with every privilege granted to \u003Ccode>anon\u003C/code>, \u003Ccode>authenticated\u003C/code> and \u003Ccode>service_role\u003C/code>.\u003C/p>\n\u003Cp>So a table with RLS on and a nice \u003Ccode>select\u003C/code> policy can still have an insert path for signed-out users if nobody revoked the grant and someone later adds a loose \u003Ccode>insert\u003C/code> policy. The docs now recommend doing it in this order, in one migration:\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-sql\">alter table public.reports enable row level security;\nrevoke all on table public.reports from anon, authenticated;\ngrant select, insert, update, delete on table public.reports to authenticated;\n\u003C/code>\u003C/pre>\n\u003Cp>A missing grant shows up as error \u003Ccode>42501\u003C/code> before any policy runs. If a request that your policy should allow keeps failing with that code, stop editing the policy and check the grants.\u003C/p>\n\u003Ch2>The mistakes that keep showing up\u003C/h2>\n\u003Cp>\u003Cstrong>RLS off on a table that was made with SQL.\u003C/strong> A table created by a migration, a script or an AI tool&#39;s generated SQL only gets RLS if the SQL says \u003Ccode>enable row level security\u003C/code>, and nothing stops you shipping without it. The Security Advisor in the dashboard flags these (lint \u003Ccode>0013_rls_disabled_in_public\u003C/code>), and I&#39;d run it before every launch.\u003C/p>\n\u003Cp>\u003Cstrong>\u003Ccode>using (true)\u003C/code> because the app &quot;didn&#39;t work&quot;.\u003C/strong> Someone enabled RLS, the app went blank (see the empty array above), and the quick fix was a policy that allows everything. For a public \u003Ccode>profiles\u003C/code> table that might even be right. For anything with user data it&#39;s the same as RLS off, just harder to spot.\u003C/p>\n\u003Cp>\u003Cstrong>An update that silently does nothing.\u003C/strong> Supabase&#39;s docs say it plainly: an \u003Ccode>UPDATE\u003C/code> needs a matching \u003Ccode>SELECT\u003C/code> policy too, because Postgres has to see the row before it can change it. Without one the update matches zero rows and returns no error. People lose an evening to this one.\u003C/p>\n\u003Cp>\u003Cstrong>A view that hands everything out.\u003C/strong> Views in Postgres run with the rights of whoever created them, which on Supabase is usually \u003Ccode>postgres\u003C/code>, which bypasses RLS. A \u003Ccode>public.user_stats\u003C/code> view over a protected table gives every caller every row. On Postgres 15 and later you fix it with \u003Ccode>with (security_invoker = true)\u003C/code> when you create the view, so it obeys the policies of the tables underneath.\u003C/p>\n\u003Cp>\u003Cstrong>Two policies that read each other.\u003C/strong> A \u003Ccode>lists\u003C/code> policy that checks \u003Ccode>list_members\u003C/code>, and a \u003Ccode>list_members\u003C/code> policy that checks \u003Ccode>lists\u003C/code>, ends in \u003Ccode>42P17: infinite recursion detected in policy for relation\u003C/code>. The way out is a \u003Ccode>security definer\u003C/code> function that reads the membership table as its owner, with \u003Ccode>search_path\u003C/code> set to an empty string and every name schema-qualified. And keep that function out of any schema the API exposes, or it becomes an endpoint with the owner&#39;s rights.\u003C/p>\n\u003Cp>\u003Cstrong>The secret key in a frontend env var.\u003C/strong> Anything prefixed \u003Ccode>NEXT_PUBLIC_\u003C/code>, \u003Ccode>VITE_\u003C/code> or \u003Ccode>NUXT_PUBLIC_\u003C/code> ends up in the bundle. If the value starts with \u003Ccode>sb_secret_\u003C/code>, or is a long \u003Ccode>eyJ...\u003C/code> JWT that turns out to be the legacy \u003Ccode>service_role\u003C/code> key, every policy in the project is decoration. Supabase is retiring the legacy \u003Ccode>anon\u003C/code> and \u003Ccode>service_role\u003C/code> keys by the end of 2026 in favour of \u003Ccode>sb_publishable_\u003C/code> and \u003Ccode>sb_secret_\u003C/code> keys, which at least makes the wrong one easier to see in a diff.\u003C/p>\n\u003Ch2>Making policies fast\u003C/h2>\n\u003Cp>RLS has a reputation for being slow. Usually it&#39;s two fixable things.\u003C/p>\n\u003Cp>Wrap the auth helpers in a sub-select: \u003Ccode>(select auth.uid()) = user_id\u003C/code> instead of \u003Ccode>auth.uid() = user_id\u003C/code>. The planner then runs the function once per statement (an \u003Ccode>initPlan\u003C/code>) instead of once per row. And index every column a policy filters on. Supabase&#39;s own \u003Ca href=\"https://supabase.com/docs/guides/database/postgres/row-level-security-performance\">RLS performance page\u003C/a> shows a count over a 100,000-row table where the policy filter forced a sequential scan, 171 ms of execution with \u003Ccode>Rows Removed by Filter: 100000\u003C/code>. An index on \u003Ccode>user_id\u003C/code> is the boring fix.\u003C/p>\n\u003Cp>One detail people miss: a composite primary key on \u003Ccode>(team_id, user_id)\u003C/code> only indexes \u003Ccode>team_id\u003C/code> for filtering. A policy on \u003Ccode>user_id\u003C/code> still needs its own index.\u003C/p>\n\u003Cp>Adding \u003Ccode>to authenticated\u003C/code> on policies that only signed-in users can pass also helps, because Postgres skips the expression entirely for \u003Ccode>anon\u003C/code> requests.\u003C/p>\n\u003Cp>I&#39;d also filter in the client query even when the policy already does it. \u003Ccode>.eq(&#39;user_id&#39;, userId)\u003C/code> looks redundant, but it gives the planner something to work with.\u003C/p>\n\u003Ch2>Test it like you mean it\u003C/h2>\n\u003Cp>Clicking around the app as two different users is a start, not a test. Supabase&#39;s guide now ends every table with \u003Ca href=\"https://supabase.com/docs/guides/database/testing\">pgTAP tests\u003C/a> run by \u003Ccode>supabase test db\u003C/code>: for each table, assert what \u003Ccode>anon\u003C/code> and \u003Ccode>authenticated\u003C/code> can and can&#39;t \u003Ccode>select\u003C/code>, \u003Ccode>insert\u003C/code>, \u003Ccode>update\u003C/code> and \u003Ccode>delete\u003C/code>. It feels like overkill until the day a migration drops a policy and the test catches it instead of a customer.\u003C/p>\n\u003Cp>If you want the reference for what Postgres itself does, the \u003Ca href=\"https://www.postgresql.org/docs/current/ddl-rowsecurity.html\">Postgres row security docs\u003C/a> and \u003Ca href=\"https://www.postgresql.org/docs/current/sql-createpolicy.html\">CREATE POLICY\u003C/a> are short and worth one read, especially the part on permissive policies combining with OR. Two loose policies on one table means the loosest one wins.\u003C/p>\n\u003Cp>If you&#39;ve got a Supabase app going to production and want someone to read the policies before your users do, \u003Ca href=\"https://calendly.com/director-norsky/meeting\">book a free call\u003C/a> and I&#39;ll go through them with you.\u003C/p>\n",1792539613588]