Supabase Row Level Security: Enable, Configure, and Verify Per-Row Access
Learn how to enable Supabase Row Level Security, create a policy that restricts rows by owner, and verify enforcement with authenticated and anonymous requests.
24 Nov 2025, 12:15 UTC

The problem: an unprotected table leaks every row
When you create a table in Supabase and expose it through the REST or GraphQL API, any client holding the public anon key can read every row unless you take an explicit server-side action. Client-side filtering in your application code does not help, because a malicious user can call the API directly. The useful takeaway is that you must enable Row Level Security (RLS) and define a policy so PostgreSQL itself filters rows before they are returned.
What RLS actually does
Row Level Security is a PostgreSQL feature that Supabase exposes through the dashboard and SQL editor. It is not enabled automatically on new tables. When RLS is active on a table, every query must satisfy at least one policy attached to that table. The policy is a SQL expression evaluated for each row. If the expression is false, the row is invisible to that request.
Supabase injects the authenticated user's identity into the database session through a JSON Web Token (JWT). Inside a policy you can call auth.uid() to retrieve the user's UUID. That lets you write rules such as "only return rows where the user_id column matches the caller."
Enable RLS and create a policy
Run the following SQL in the Supabase SQL Editor. You need project owner or admin privileges, or ownership of the table.
alter table documents enable row level security;
create policy user_owns_documents
on documents
for all
using (auth.uid() = user_id);
The first statement turns on RLS for the documents table. The second creates a policy named user_owns_documents. The for all clause means the policy applies to SELECT, INSERT, UPDATE, and DELETE operations. The using expression is evaluated against each existing row: only rows where the user_id column equals the caller's auth.uid() are visible or modifiable.
After running these statements, client SDK calls such as supabase.from('documents').select() automatically respect the policy when the request carries either the anon key or a user's access token. No application code changes are required.
Verify the policy is enforced
Do not assume the policy works. Test it from two angles.
First, sign in as a user and query the table through the client SDK or the REST API. You should receive only rows where user_id matches that user's auth.uid(). If you see other users' rows, RLS is not enabled or the policy expression is wrong.
Second, run the same query with the anon key while unauthenticated. The result should be zero rows, because no policy grants anonymous access. If the anon request still returns data, you have either not enabled RLS or you have a permissive policy that allows public reads.
You can also confirm the state in the Supabase Dashboard: open the table editor for documents and check that Row Level Security is enabled and the policy appears in the policies list.
Limits and common mistakes
RLS is off by default per table
Enabling RLS on one table does not protect others. Every table that holds user data needs its own alter table ... enable row level security; statement and its own policies. A table with RLS disabled returns all rows regardless of what other tables do.
Policies are additive
If you attach multiple policies to the same table and operation, PostgreSQL combines them with OR logic. A row is visible if it satisfies any one policy. This means a single overly broad policy—for example, one that allows select for everyone—will defeat a narrow ownership policy on the same table. Audit your policies together, not one by one.
The service_role key bypasses RLS
The service_role key is a powerful secret that skips Row Level Security entirely. It is intended for trusted backend environments only. If you embed it in a frontend bundle, mobile app, or public repository, an attacker can read and write every row in every table. Store it only in server-side environment variables and never expose it to the browser.
Complex policies can hurt performance
Policy expressions run for every row the database considers. If your policy references a column that is not indexed, PostgreSQL may perform a sequential scan on large tables. Index the columns used inside policy expressions—for the example above, an index on user_id is appropriate. Test query plans on tables with realistic row counts before relying on a complex policy in production.
Client-side checks are not a substitute
Hiding buttons or filtering results in JavaScript is a user-experience choice, not a security control. RLS is the enforcement point. If your policy is wrong, the data is exposed no matter what the UI shows.
A practical checklist
- Enable RLS on every table that stores user data.
- Write at least one policy per table and operation that reflects your access model.
- Test with an authenticated user and with the anon key.
- Keep the service_role key out of client code.
- Index columns referenced in policy expressions.
Row Level Security moves access control into the database, which is the only place a determined attacker cannot bypass. Start with a simple ownership policy, verify it with both authenticated and anonymous requests, and expand carefully.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.