Enabling Row Level Security is an important step in securing a Supabase application, but enabling RLS is not the same thing as defining who should be able to access each row.
A common configuration looks like this:
alter table public.projects
enable row level security;
RLS is enabled, but no policy has been created.
This does not mean that every user can suddenly read the table. PostgreSQL does the opposite: once row security is enabled, normal row access must be allowed by an applicable policy. If no policy exists, PostgreSQL uses a default-deny policy, so rows are not visible and cannot normally be modified.
In a Supabase application, one common symptom is surprisingly simple: the rows exist in the database, but the client receives an empty result.
That makes this different from the more obvious failure of leaving an exposed table without RLS. RLS enabled with zero policies is usually an incomplete access-control configuration: the database is denying normal access, but the application still does not have the policies it needs.
RLS is an additional access-control layer
PostgreSQL privileges and RLS solve related but different problems. Traditional privileges such as GRANT SELECT determine whether a role can perform an operation on a table at all. RLS then determines which rows that role may access.
In Supabase, unauthenticated Data API requests normally use the anon PostgreSQL role, while signed-in requests normally use authenticated. Supabase recommends enabling RLS on tables in exposed schemas and granting only the privileges each role actually needs.
table privilege
↓
is SELECT / INSERT / UPDATE / DELETE allowed for this role?
↓
RLS policy
↓
which rows may this role access?
Enabling RLS without defining an applicable policy leaves the row-level layer closed.
What does “no policies” actually mean?
Case 1: the table has no RLS policies at all
This is the simplest configuration problem:
RLS enabled + zero policies
PostgreSQL applies default deny. For ordinary access subject to RLS, no rows are visible or modifiable until an applicable policy allows them.
Case 2: policies exist, but none apply to the request
This can produce very similar application behavior. A policy can be limited by PostgreSQL role, command such as SELECT or INSERT, its USING expression, or its WITH CHECK expression.
For example, a policy restricted to authenticated will not grant access to an unauthenticated request using the anon role.
That distinction matters during debugging: “there is a policy” is not the same as “there is an applicable policy.”
Why this often appears as an empty result in Supabase
Suppose public.projects contains rows, but your application query returns an empty array.
[]
If RLS is enabled, possible explanations include:
- there is no policy,
- the current role does not match the policy,
- the authenticated user does not satisfy the policy expression,
- or an application filter legitimately matches no rows.
Supabase specifically calls out enabled RLS with no matching policy as a common cause of empty results when rows exist.
A diagnostic query for tables with RLS but zero policies
PostgreSQL exposes enough catalog information to detect this condition directly. pg_class.relrowsecurity indicates whether row security is enabled for a relation, while pg_policy stores policies and links them to tables through the relation OID.
For the default Supabase application schema:
select
n.nspname as schema_name,
c.relname as table_name
from pg_class c
join pg_namespace n
on n.oid = c.relnamespace
where c.relkind in ('r', 'p')
and c.relrowsecurity
and n.nspname = 'public'
and not exists (
select 1
from pg_policy p
where p.polrelid = c.oid
)
order by
n.nspname,
c.relname;
If this returns tables, you have identified relations where RLS is enabled and PostgreSQL currently has zero policies defined for the table.
This query does not tell you what the policies should be. That requires understanding the application’s ownership, membership and access model. It answers one deterministic question: which application tables have RLS enabled but no policy definitions?
If your application exposes additional schemas, expand the schema filter deliberately rather than scanning PostgreSQL and Supabase internal schemas indiscriminately.
Inspect the policies that do exist
For tables that already have policies, PostgreSQL exposes the pg_policies view.
select
schemaname,
tablename,
policyname,
roles,
cmd,
qual,
with_check
from pg_policies
where schemaname = 'public'
order by
tablename,
policyname;
This gives you a more useful review surface than simply checking whether the RLS toggle is on. It shows which roles and commands a policy targets and exposes the policy expressions that govern row visibility and row checks.
Do not trust a privileged database test as your only RLS test
One of the easiest ways to misdiagnose an RLS problem is to test with a privileged database role and assume that application users will see the same result.
PostgreSQL superusers and roles with BYPASSRLS bypass row security. Table owners also normally bypass RLS unless FORCE ROW LEVEL SECURITY is enabled.
A successful privileged query therefore does not prove that an anon or authenticated application request can see the same rows.
The service role is not an RLS user test
Supabase service-role and secret keys are intended for trusted server-side access and can bypass RLS. They should never be exposed in the browser.
That makes privileged server access useful for backend operations, but unsuitable as evidence that your client-facing RLS policies are correct.
| Context | What you are testing |
|---|---|
anon | Unauthenticated application behavior |
authenticated user A | Allowed rows for one real user |
authenticated user B | User or tenant isolation |
| Privileged/server role | Backend behavior, not client RLS correctness |
Do not “fix” the problem by blindly adding USING (true)
Once you discover that RLS is blocking the application, a tempting fix is a policy with:
using (true)
That can be correct when broad row access is genuinely the requirement. Supabase documentation includes deliberately public policies that use true. The problem is using a broad predicate as an automatic debugging shortcut without first defining the intended access model.
The better question is not “How do I make this query work?” It is: Which actors should be allowed to perform which operations on which rows?
For a user-owned table, an intentionally scoped read policy might look like this:
create policy "Users can read their own profile"
on public.profiles
for select
to authenticated
using (
(select auth.uid()) = user_id
);
This is only an illustrative ownership model. Your application may instead use organization membership, project membership, team roles, explicitly public rows or another documented authorization predicate. Do not copy an auth.uid() = user_id policy into a system whose ownership model is different.
A practical remediation sequence
1. Identify the actors
List the real authorization contexts: anonymous visitor, authenticated user, organization member, organization owner, trusted backend or other application-specific roles.
2. Identify the operations
Review SELECT, INSERT, UPDATE and DELETE separately. A user who may read a row does not automatically need permission to update or delete it.
3. Define the ownership or membership rule
Examples include a row owned by the current user, an organization the current user belongs to, an explicitly public row, or a documented administrative membership rule.
4. Create the smallest policies that implement that model
Prefer explicit roles and explicit command scope. Avoid broad access simply to make a failing query return data.
5. Review USING and WITH CHECK separately
Visibility of existing rows and permission to create or modify rows are not always governed by the same predicate. PostgreSQL distinguishes policy conditions used for existing rows from checks applied to new row values.
6. Test with real application authorization contexts
At minimum, test an unauthenticated request where relevant, an allowed authenticated user and a different authenticated user. For multi-tenant systems, explicitly verify that one tenant cannot access another tenant’s rows.
7. Retest the application path
A policy is successful when real application behavior matches the intended access model, not merely when a privileged SQL query starts returning data.
Pre-launch RLS checklist
- RLS is enabled where required.
- Every exposed application table has an intentional policy model.
- Policies target the intended PostgreSQL roles.
SELECT,INSERT,UPDATEandDELETEhave been considered separately.USINGexpressions match the intended row-visibility rules.WITH CHECKexpressions match intended insert/update ownership rules.- Table privileges and RLS rules agree with each other.
anonbehavior has been tested where relevant.- Authenticated behavior has been tested with more than one user where isolation matters.
- A privileged query has not been used as the only test.
- Broad
truepolicies exist only where broad access is intentional. - Multi-tenant boundaries have been tested explicitly.
The important distinction
There are two different configuration problems:
RLS disabled on an exposed user-facing table versus RLS enabled but no policy exists
The first can remove row-level protection you expected. The second normally produces default-deny behavior for ordinary access subject to RLS. Neither should be treated as a finished authorization design.
The objective is not merely to switch RLS on. The objective is to create an explicit, testable access model in which each application role receives exactly the row access it needs.
Preparing a Supabase application for launch?
The Supabase Launch Audit reviews RLS enablement, policies, ownership predicates, multi-tenancy, schema design, keys, indexes and other visible production-readiness risks. It produces prioritized findings, evidence, remediation guidance and a readiness checklist. Starting at $499. It is not a security certification.