RLS moves authorisation out of your application and into the database, where it applies to every client at once. That is the strength and the reason a mistake in it is total rather than local.
Off by default means public
PostgreSQL ships row level security disabled per table. In a normal backend that is fine, because the only thing holding the connection string is your server. In Supabase the browser talks to PostgREST directly with a key that anyone can read out of the bundle, so a table without RLS is a table on the public internet.
Two statements, and they do different things:
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;turns it on. With no policies, nothing is visible to anyone, which is the correct default: a table that denies everything is a bug report, a table that allows everything is an incident.ALTER TABLE posts FORCE ROW LEVEL SECURITY;makes the policies apply to the table owner as well. Without it, the owner bypasses them, which is why a policy can look broken during testing in the SQL editor and work perfectly from the client.
The Supabase dashboard flags tables that are exposed without RLS, and that warning is worth treating as an outage-level alert rather than a lint. A single unprotected table with a foreign key to your users is enough to enumerate the user list.
USING and WITH CHECK
A policy carries up to two expressions, and the difference between them is where most real holes come from.
USING applies to rows that already exist. It decides what SELECT returns and which rows UPDATE and DELETE are permitted to touch. WITH CHECK applies to rows on the way in: what INSERT may create, and what a row is allowed to look like after an UPDATE.
| Command | USING | WITH CHECK |
|---|---|---|
SELECT | yes | not applicable |
INSERT | not applicable | yes |
UPDATE | which rows may be changed | what they may become |
DELETE | yes | not applicable |
The hole is an UPDATE policy with only a USING clause. The user may edit rows where user_id = auth.uid(), and nothing constrains the result, so they can set user_id to another user's id and hand the row over. On a billing or documents table that is a real finding. Both clauses are usually the same expression, and writing it twice is the point rather than duplication.
Write one policy per command rather than a single FOR ALL. It is more lines and it is far easier to review, because "who may delete this" is then a question with its own visible answer.
Add TO authenticated or TO anon to every policy. It documents intent, and it also means the planner skips the policy entirely for other roles instead of evaluating an expression that cannot pass.
anon, authenticated, service_role
Supabase maps its keys onto PostgreSQL roles, and the mapping explains what each key can do.
anon is the unauthenticated visitor. The anon key is meant to be public: it identifies the project, not a person, and it is safe in a bundle exactly to the extent that your policies are correct.
authenticated is a signed-in user. The JWT travels with the request, and auth.uid() reads its sub claim while auth.jwt() exposes the rest. Custom claims can drive policies, which is convenient and inherits every property of the token: claims are a snapshot from issue time, so a role revoked five minutes ago is still in a token that lives an hour. That trade-off, and the verification rules that go with it, is the subject of five JWT mistakes.
service_role holds BYPASSRLS. Every policy is ignored. It exists for server-side code that legitimately needs to see everything, and it belongs on a server and nowhere else. The failure mode is specific and common: the service key gets used to make something work during development, then ends up behind a NEXT_PUBLIC_ or VITE_ prefix, and those prefixes compile the value into JavaScript every visitor downloads. If that has happened, rotate first and clean up the repository afterwards, in that order, for the reasons in stop committing secrets.
The recursion trap
Multi-tenant schemas hit this within a day. You want members of an organisation to see each other, so the policy on members checks whether the caller is a member of the same organisation, by querying members. Evaluating the policy requires evaluating the policy, and PostgreSQL stops with infinite recursion detected in policy for relation "members".
Two ways out. The usual one is a SECURITY DEFINER function that performs the lookup with the owner's rights, so the policy is not re-entered:
create function auth.org_ids() returns setof uuid language sql security definer stable set search_path = '' as $$ select org_id from public.members where user_id = auth.uid() $$;
and then a policy of the form org_id in (select auth.org_ids()). Mark it STABLE so the result can be cached within the statement, and always pin search_path, because a SECURITY DEFINER function with a mutable search path is a privilege escalation waiting for someone to create a same-named object in a schema they control.
The other way out is to denormalise: copy the tenant id onto each row so the policy compares two columns and never leaves the table. More writes, no recursion, and much easier to read six months later. On tables that are read constantly and written rarely, we would take the denormalised version every time.
Why your queries got slow
A policy is not a separate access-control layer that runs before the query. It is a predicate the planner adds to the query, so everything you know about slow WHERE clauses applies unchanged.
Three things account for most of the slowdown after enabling RLS:
- Per-row function calls. A bare
auth.uid()in a policy can be evaluated for every row examined. Written as(select auth.uid())the planner can treat it as an InitPlan and run it once. On a table with hundreds of thousands of rows the difference is not subtle, and it is the single most effective RLS optimisation there is. - Missing indexes. A policy filtering on
user_idneeds an index onuser_id. Without it, every query the policy touches is a sequential scan, and it will not look like a policy problem in your metrics. - Subqueries in the predicate. A policy joining two other tables runs that join for every query against the protected table. A
SECURITY DEFINERhelper returning a small set is usually faster than anEXISTSthe planner has to re-derive.
The diagnosis is ordinary: EXPLAIN ANALYZE as the authenticated role, and look for the policy expression appearing as a filter with a large row count above it. Our SQL query optimizer takes a query plus your DDL and names the index that is missing, which works the same way whether the predicate came from your WHERE clause or from a policy, and the SQL formatter makes a generated PostgREST query readable enough to reason about in the first place.
Where RLS does not reach
Policies apply to tables. Several things that look like tables are not.
Views. A view has historically run with its owner's privileges, so a view over a protected table returns every row to anyone who can select from the view. PostgreSQL 15 added security_invoker = true, which makes the view run as the caller and respect their policies. Create views with it, and audit any view created before you knew this.
SECURITY DEFINER functions. They run as their owner by design, which is what makes them useful for breaking recursion and what makes them a bypass. Keep them small, keep them few, and pin search_path on every one.
Storage and realtime. Supabase Storage objects and realtime subscriptions have their own policies. A file bucket left open is exactly as public as a table left open, and it is easy to forget because the buckets are configured elsewhere.
Anything reached by the service key. Server-side code with service_role must reimplement the authorisation your policies express, because the database will not do it for you there. An edge function using the service key to "just fetch the user's data" is where tenant isolation quietly ends.
Column visibility. RLS filters rows, not columns. If a row contains a field the user must not see, use column privileges or a view with security_invoker, and remember that PostgREST will happily return any column the role can select.
Testing a policy properly
Policies fail open, quietly, and only for other people's data. Test them the way you would test authorisation code, because that is what they are.
Impersonate the role and the claims in a transaction, then roll back:
begin; set local role authenticated; set local request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}'; select * from posts; rollback;
Then write the negative assertions, which are the ones that matter: user B cannot select user A's rows, cannot update them, cannot reassign one to themselves by changing the owner column, and cannot delete them. Keep those in the migration test suite so the next policy change has to pass them.
A short review checklist that catches most of it:
- RLS enabled on every table reachable by the anon key, and
FORCEwhere the owner also connects. - Every
UPDATEpolicy has aWITH CHECK, not just aUSING. - Every policy names a role with
TO. auth.uid()wrapped in a subquery, and the filtered column indexed.- Views created with
security_invoker = true. - No
service_rolekey anywhere a browser can reach.
When you are writing the client-side queries these policies govern, the shape of a PostgREST filter is its own small language, and our Supabase query generator turns SQL into supabase-js calls with joins expressed as embeds. The policy reference itself is in the Supabase row level security documentation, which is one of the better pieces of writing on the underlying PostgreSQL feature regardless of which client you use.
Questions about row level security
What is the difference between USING and WITH CHECK in a policy?
USING filters rows that already exist: it decides what SELECT returns and which rows UPDATE and DELETE are allowed to touch. WITH CHECK validates rows on the way in: it decides what INSERT may create and what a row is allowed to look like after an UPDATE. An UPDATE policy needs both, and this is the classic hole. With only USING, a user can edit their own row and set user_id to someone else’s, handing it over; the WITH CHECK expression is what stops that.
Is it safe to expose the Supabase anon key in the browser?
Yes, that is what it is for, provided every table it can reach has RLS enabled with policies you have actually tested. The anon key identifies the project and the role, not a user; the authorisation comes from the policies and the user’s JWT. What is never safe in the browser is the service_role key, which carries BYPASSRLS and ignores every policy you wrote. Putting it behind a NEXT_PUBLIC_ or VITE_ prefix compiles it into the bundle, which is a full database compromise.
Why do I get "infinite recursion detected in policy for relation"?
Because the policy on a table queries that same table, so evaluating it requires evaluating it. The usual shape is a members table whose policy checks whether you are a member of the same organisation. Break the loop with a SECURITY DEFINER function that reads the table with the owner’s rights and is called from the policy, or denormalise the check onto a column the policy can read directly. Mark the function STABLE and set an empty search_path on it.
Why did enabling RLS make my queries slow?
Because a policy is a WHERE clause the planner adds to every query, and two things commonly go wrong with it. If auth.uid() appears bare, it can be evaluated per row; wrapping it as (select auth.uid()) lets the planner run it once as an InitPlan, which on large tables is a large difference. And the column the policy filters on needs an index just like any other predicate, so a policy on user_id without an index on user_id turns every query into a sequential scan.
Do views and functions respect row level security?
Not automatically, and this is the most common way a carefully written policy set gets bypassed. A view historically runs with the rights of its owner, so a view over a protected table exposes every row until you create it with security_invoker = true, available from PostgreSQL 15. A SECURITY DEFINER function likewise executes as its owner and ignores the caller’s policies by design. Both are useful tools and both need to be treated as holes you opened deliberately.
How do I test an RLS policy without logging in as a real user?
In a psql session, impersonate the role and the claims: set local role authenticated, then set local request.jwt.claims to a JSON object containing the sub you want to test, then run the query. Wrap it in a transaction and roll back. Testing the negative case matters more than the positive one, so assert that user B cannot read user A’s rows rather than only that user A can read their own, and keep those assertions in your migration test suite where a future policy change will run them again.
Are multiple policies on a table combined with AND or OR?
Permissive policies, the default, are combined with OR: a row is visible if any of them allows it, which makes them easy to reason about one case at a time and easy to widen by accident. Restrictive policies are combined with AND and must all pass, so they are the right tool for a cross-cutting rule such as "and the tenant must not be suspended". A table with only restrictive policies allows nothing, since there is no permissive policy to grant anything in the first place.
Does RLS protect me if the service_role key leaks?
No. That role is granted BYPASSRLS precisely so server-side code can ignore policies, so a leaked key gives full read and write access to every table regardless of what you wrote. Treat it like a database superuser password: server-side only, never in a client bundle, never in a repository, rotated immediately if it is ever exposed. If it has been committed, rotate before you rewrite any git history.