What is RLS and why every Supabase app needs it
Row Level Security is one of Postgres's most powerful built-in features, and Supabase makes it the primary access control mechanism for all client-side database access. The core idea is simple: rather than controlling access at the application layer — by filtering rows in your API code before returning them to the user — you control it at the database layer, where the database itself refuses to return rows that the requesting role should not see. This means that even if your application code has a bug, even if an attacker bypasses your API entirely and queries the database directly with your public anon key, the database enforces your access rules and returns nothing it should not. RLS is the last line of defense that works even when everything above it fails.
Without RLS, your Supabase anon key — which is designed to be public and is embedded in your client-side JavaScript bundle — grants any visitor the ability to run arbitrary queries against your database using the Supabase JavaScript client. They can read every row in every table: user profiles, private messages, payment records, health data, or whatever your application stores. They can also insert, update, and delete rows. The anon key alone, used with the Supabase client library, is sufficient to do all of this if RLS is not enabled. This is not a hypothetical edge case — it is how Supabase works by design, because it assumes developers will enable RLS to restrict what the anon role can access. When they don't, the anon key becomes a skeleton key to the entire database.
Enabling RLS changes the default from "allow everything" to "deny everything." Once you turn on RLS for a table in the Supabase table editor, all access to that table by non-service-role database roles is immediately blocked — including your own authenticated users — until you add policies that explicitly allow it. This might sound disruptive, but it is the correct security posture. You then add policies that precisely describe what each role is allowed to do. A typical set of policies for a user-owned table looks like this: SELECT requires that the requesting user's ID matches the row's user_id column; INSERT requires that theuser_idbeing inserted matches the authenticated user's ID; UPDATE and DELETE have the same condition. The result is that each user can only ever see and modify their own rows, enforced at the Postgres level regardless of what your application code does.
Writing effective RLS policies requires understanding Supabase's auth helper functions. The most important is auth.uid(), which returns the user ID from the JWT token in the current request. A policy using USING (auth.uid() = user_id) allows a row to be selected only when the authenticated user's ID matches the row's user_id field. For INSERT policies, use WITH CHECK (auth.uid() = user_id) to prevent users from inserting rows with a user_id that does not belong to them. The service role bypasses all of these policies automatically — it is designed for administrative use from your server-side code — which is why it must never be exposed to the client.
The right way to verify your RLS policies are working is to test them from the anon role's perspective. In the Supabase dashboard, open the SQL Editor and switch the execution role to "anon" using the role dropdown. Then run a simple SELECT * FROM your_table query. If RLS is correctly enabled and configured, you should get zero rows back, because the anon user has no user ID and therefore matches no auth.uid() = user_idcondition. You can also test as a specific authenticated user by using the "Test as user" feature to set a user JWT and then confirming that queries return only that user's rows. This end-to-end verification is the only way to be certain your policies are working as intended.